Create the forntend panel the control
This commit is contained in:
@@ -1,298 +0,0 @@
|
|||||||
import React, { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Card, CardHeader, CardBody, CardFooter, Button, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export interface DashboardCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
||||||
// Card variants for different dashboard contexts
|
|
||||||
variant?: 'metric' | 'chart' | 'list' | 'activity' | 'status' | 'action';
|
|
||||||
|
|
||||||
// Header props
|
|
||||||
title?: ReactNode;
|
|
||||||
subtitle?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
headerActions?: ReactNode;
|
|
||||||
|
|
||||||
// Loading and state management
|
|
||||||
isLoading?: boolean;
|
|
||||||
hasError?: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
isEmpty?: boolean;
|
|
||||||
emptyMessage?: string;
|
|
||||||
|
|
||||||
// Footer props
|
|
||||||
footerActions?: ReactNode;
|
|
||||||
footerText?: ReactNode;
|
|
||||||
|
|
||||||
// Badge/notification support
|
|
||||||
badge?: string | number;
|
|
||||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
// Interactive behavior
|
|
||||||
interactive?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
|
|
||||||
// Layout customization
|
|
||||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
headerPadding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
bodyPadding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
// Accessibility
|
|
||||||
'aria-label'?: string;
|
|
||||||
'aria-describedby'?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardCard = forwardRef<HTMLDivElement, DashboardCardProps>(({
|
|
||||||
variant = 'metric',
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
icon,
|
|
||||||
headerActions,
|
|
||||||
isLoading = false,
|
|
||||||
hasError = false,
|
|
||||||
errorMessage = 'Ha ocurrido un error',
|
|
||||||
isEmpty = false,
|
|
||||||
emptyMessage = 'No hay datos disponibles',
|
|
||||||
footerActions,
|
|
||||||
footerText,
|
|
||||||
badge,
|
|
||||||
badgeVariant = 'primary',
|
|
||||||
interactive = false,
|
|
||||||
onClick,
|
|
||||||
onRefresh,
|
|
||||||
padding = 'md',
|
|
||||||
headerPadding,
|
|
||||||
bodyPadding,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
'aria-describedby': ariaDescribedby,
|
|
||||||
...props
|
|
||||||
}, ref) => {
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
if (onRefresh && !isRefreshing) {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await onRefresh();
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
metric: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
|
||||||
chart: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
|
||||||
list: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
|
||||||
activity: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
|
||||||
status: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
|
||||||
action: 'bg-[var(--bg-primary)] border-[var(--border-primary)] hover:border-[var(--border-secondary)] hover:shadow-lg'
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardClasses = clsx(
|
|
||||||
variantStyles[variant],
|
|
||||||
'transition-all duration-300',
|
|
||||||
{
|
|
||||||
'cursor-pointer transform hover:-translate-y-1': interactive || onClick,
|
|
||||||
'opacity-50': isLoading,
|
|
||||||
'border-[var(--color-error)]/30 bg-[var(--color-error)]/5': hasError,
|
|
||||||
},
|
|
||||||
className
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasHeader = title || subtitle || icon || headerActions || badge || onRefresh;
|
|
||||||
|
|
||||||
const renderSkeletonContent = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case 'metric':
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="h-8 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
|
||||||
<div className="h-12 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
|
||||||
<div className="h-4 bg-[var(--bg-quaternary)] rounded animate-pulse w-2/3" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'chart':
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="h-6 bg-[var(--bg-quaternary)] rounded animate-pulse w-1/2" />
|
|
||||||
<div className="h-32 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'list':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center space-x-3">
|
|
||||||
<div className="w-8 h-8 bg-[var(--bg-quaternary)] rounded-full animate-pulse" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-4 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
|
||||||
<div className="h-3 bg-[var(--bg-quaternary)] rounded animate-pulse w-2/3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <div className="h-24 bg-[var(--bg-quaternary)] rounded animate-pulse" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderErrorContent = () => (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-[var(--color-error)] mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-4">{errorMessage}</p>
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
isLoading={isRefreshing}
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEmptyContent = () => (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-[var(--text-tertiary)] mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<p className="text-[var(--text-tertiary)]">{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={ref}
|
|
||||||
className={cardClasses}
|
|
||||||
padding={padding}
|
|
||||||
interactive={interactive}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-describedby={ariaDescribedby}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{hasHeader && (
|
|
||||||
<CardHeader
|
|
||||||
padding={headerPadding}
|
|
||||||
className="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
|
||||||
{icon && (
|
|
||||||
<div className="flex-shrink-0 text-[var(--text-secondary)]">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{title && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] truncate">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{badge && (
|
|
||||||
<Badge
|
|
||||||
variant={badgeVariant}
|
|
||||||
size="sm"
|
|
||||||
shape="pill"
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mt-1 truncate">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
isLoading={isRefreshing}
|
|
||||||
aria-label="Actualizar datos"
|
|
||||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{headerActions}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBody padding={bodyPadding}>
|
|
||||||
{isLoading
|
|
||||||
? renderSkeletonContent()
|
|
||||||
: hasError
|
|
||||||
? renderErrorContent()
|
|
||||||
: isEmpty
|
|
||||||
? renderEmptyContent()
|
|
||||||
: children}
|
|
||||||
</CardBody>
|
|
||||||
|
|
||||||
{(footerActions || footerText) && (
|
|
||||||
<CardFooter className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
{footerText}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{footerActions}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DashboardCard.displayName = 'DashboardCard';
|
|
||||||
|
|
||||||
export default DashboardCard;
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
TrendingUp,
|
|
||||||
Package,
|
|
||||||
AlertCircle,
|
|
||||||
DollarSign
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export const DashboardGrid: React.FC = () => {
|
|
||||||
// Simple placeholder implementation - can be enhanced later
|
|
||||||
const mockData = {
|
|
||||||
sales_today: 1247,
|
|
||||||
sales_change: 12.5,
|
|
||||||
products_sold: 45,
|
|
||||||
products_change: 8.3,
|
|
||||||
active_alerts: 2,
|
|
||||||
urgent_alerts: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{/* KPI Cards */}
|
|
||||||
<KPICard
|
|
||||||
title="Ventas Hoy"
|
|
||||||
value={`€${mockData.sales_today}`}
|
|
||||||
change={mockData.sales_change}
|
|
||||||
icon={<DollarSign className="w-5 h-5" />}
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KPICard
|
|
||||||
title="Productos Vendidos"
|
|
||||||
value={mockData.products_sold}
|
|
||||||
change={mockData.products_change}
|
|
||||||
icon={<Package className="w-5 h-5" />}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KPICard
|
|
||||||
title="Alertas Activas"
|
|
||||||
value={mockData.active_alerts}
|
|
||||||
urgent={mockData.urgent_alerts}
|
|
||||||
icon={<AlertCircle className="w-5 h-5" />}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="bg-gradient-to-br from-blue-50 to-cyan-50 border-[var(--color-info)]/20">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<AlertCircle className="w-8 h-8 text-blue-500" />
|
|
||||||
<Badge variant="secondary">Sistema</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-2xl font-bold">En línea</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Todo funcionando</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KPI Card Component
|
|
||||||
interface KPICardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
change?: number;
|
|
||||||
urgent?: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
color: 'green' | 'blue' | 'orange' | 'red';
|
|
||||||
}
|
|
||||||
|
|
||||||
const KPICard: React.FC<KPICardProps> = ({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
change,
|
|
||||||
urgent,
|
|
||||||
icon,
|
|
||||||
color
|
|
||||||
}) => {
|
|
||||||
const colorClasses = {
|
|
||||||
green: 'bg-green-50 text-[var(--color-success)] border-green-200',
|
|
||||||
blue: 'bg-[var(--color-info)]/5 text-[var(--color-info)] border-[var(--color-info)]/20',
|
|
||||||
orange: 'bg-orange-50 text-[var(--color-primary)] border-orange-200',
|
|
||||||
red: 'bg-red-50 text-[var(--color-error)] border-red-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={`relative overflow-hidden ${colorClasses[color]} border-2`}>
|
|
||||||
<Card.Content className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className={`p-2 rounded-lg ${color === 'green' ? 'bg-[var(--color-success)]/10' : color === 'blue' ? 'bg-[var(--color-info)]/10' : color === 'orange' ? 'bg-[var(--color-primary)]/10' : 'bg-[var(--color-error)]/10'}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
{urgent !== undefined && urgent > 0 && (
|
|
||||||
<Badge variant="destructive" className="animate-pulse">
|
|
||||||
{urgent} Urgente
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">{title}</p>
|
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
|
||||||
|
|
||||||
{change !== undefined && (
|
|
||||||
<div className="flex items-center space-x-1 text-sm">
|
|
||||||
<TrendingUp className={`w-4 h-4 ${change >= 0 ? 'text-green-500' : 'text-red-500 rotate-180'}`} />
|
|
||||||
<span className={change >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
|
||||||
{Math.abs(change)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--text-tertiary)]">vs ayer</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardGrid;
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import DashboardCard from './DashboardCard';
|
|
||||||
|
|
||||||
export interface KPIValue {
|
|
||||||
current: number;
|
|
||||||
previous?: number;
|
|
||||||
target?: number;
|
|
||||||
format: 'currency' | 'number' | 'percentage';
|
|
||||||
prefix?: string;
|
|
||||||
suffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KPITrend {
|
|
||||||
direction: 'up' | 'down' | 'stable';
|
|
||||||
value: number;
|
|
||||||
isPositive: boolean;
|
|
||||||
comparisonPeriod: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KPIThreshold {
|
|
||||||
excellent: number;
|
|
||||||
good: number;
|
|
||||||
warning: number;
|
|
||||||
critical: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparklineDataPoint {
|
|
||||||
date: string;
|
|
||||||
value: number;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KPIWidgetProps {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: KPIValue;
|
|
||||||
trend?: KPITrend;
|
|
||||||
|
|
||||||
// Visual configuration
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
color?: 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'indigo' | 'teal';
|
|
||||||
variant?: 'default' | 'compact' | 'detailed' | 'chart';
|
|
||||||
|
|
||||||
// Chart data
|
|
||||||
sparklineData?: SparklineDataPoint[];
|
|
||||||
showSparkline?: boolean;
|
|
||||||
|
|
||||||
// Thresholds and status
|
|
||||||
thresholds?: KPIThreshold;
|
|
||||||
status?: 'excellent' | 'good' | 'warning' | 'critical' | 'neutral';
|
|
||||||
|
|
||||||
// Comparison and context
|
|
||||||
comparisonLabel?: string;
|
|
||||||
contextInfo?: string;
|
|
||||||
|
|
||||||
// Interactive features
|
|
||||||
isLoading?: boolean;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onClick?: () => void;
|
|
||||||
|
|
||||||
// Accessibility and styling
|
|
||||||
className?: string;
|
|
||||||
'aria-label'?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predefined bakery KPI configurations
|
|
||||||
export const BAKERY_KPI_CONFIGS = {
|
|
||||||
dailyRevenue: {
|
|
||||||
title: 'Ingresos Hoy',
|
|
||||||
subtitle: 'Ventas del día actual',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'green' as const,
|
|
||||||
format: 'currency' as const
|
|
||||||
},
|
|
||||||
orderCount: {
|
|
||||||
title: 'Pedidos',
|
|
||||||
subtitle: 'Órdenes procesadas hoy',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'blue' as const,
|
|
||||||
format: 'number' as const
|
|
||||||
},
|
|
||||||
productivity: {
|
|
||||||
title: 'Productividad',
|
|
||||||
subtitle: 'Unidades producidas por hora',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'orange' as const,
|
|
||||||
format: 'number' as const,
|
|
||||||
suffix: '/h'
|
|
||||||
},
|
|
||||||
stockLevel: {
|
|
||||||
title: 'Nivel Stock',
|
|
||||||
subtitle: 'Porcentaje de stock disponible',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'purple' as const,
|
|
||||||
format: 'percentage' as const
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatValue = (value: number, format: KPIValue['format'], prefix?: string, suffix?: string): string => {
|
|
||||||
let formatted: string;
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case 'currency':
|
|
||||||
formatted = new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
}).format(value);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'percentage':
|
|
||||||
formatted = new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'percent',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 1
|
|
||||||
}).format(value / 100);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
default:
|
|
||||||
formatted = new Intl.NumberFormat('es-ES').format(value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${prefix || ''}${formatted}${suffix || ''}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTrend = (current: number, previous: number): KPITrend => {
|
|
||||||
// Handle undefined or null values
|
|
||||||
if (current == null || previous == null ||
|
|
||||||
typeof current !== 'number' || typeof previous !== 'number' ||
|
|
||||||
isNaN(current) || isNaN(previous)) {
|
|
||||||
return {
|
|
||||||
direction: 'stable',
|
|
||||||
value: 0,
|
|
||||||
isPositive: true,
|
|
||||||
comparisonPeriod: 'vs período anterior'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const change = current - previous;
|
|
||||||
const percentChange = previous !== 0 ? (change / previous) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
direction: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
|
||||||
value: Math.abs(percentChange),
|
|
||||||
isPositive: change >= 0,
|
|
||||||
comparisonPeriod: 'vs período anterior'
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: KPIWidgetProps['status']) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'excellent':
|
|
||||||
return {
|
|
||||||
bg: 'bg-[var(--color-success)]/10',
|
|
||||||
text: 'text-[var(--color-success)]',
|
|
||||||
border: 'border-[var(--color-success)]/20',
|
|
||||||
icon: 'text-[var(--color-success)]'
|
|
||||||
};
|
|
||||||
case 'good':
|
|
||||||
return {
|
|
||||||
bg: 'bg-[var(--color-info)]/10',
|
|
||||||
text: 'text-[var(--color-info)]',
|
|
||||||
border: 'border-[var(--color-info)]/20',
|
|
||||||
icon: 'text-[var(--color-info)]'
|
|
||||||
};
|
|
||||||
case 'warning':
|
|
||||||
return {
|
|
||||||
bg: 'bg-[var(--color-warning)]/10',
|
|
||||||
text: 'text-[var(--color-warning)]',
|
|
||||||
border: 'border-[var(--color-warning)]/20',
|
|
||||||
icon: 'text-[var(--color-warning)]'
|
|
||||||
};
|
|
||||||
case 'critical':
|
|
||||||
return {
|
|
||||||
bg: 'bg-[var(--color-error)]/10',
|
|
||||||
text: 'text-[var(--color-error)]',
|
|
||||||
border: 'border-[var(--color-error)]/20',
|
|
||||||
icon: 'text-[var(--color-error)]'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
bg: 'bg-[var(--bg-tertiary)]',
|
|
||||||
text: 'text-[var(--text-secondary)]',
|
|
||||||
border: 'border-[var(--border-primary)]',
|
|
||||||
icon: 'text-[var(--text-tertiary)]'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const SimpleSparkline: React.FC<{ data: SparklineDataPoint[]; color: string }> = ({ data, color }) => {
|
|
||||||
const max = Math.max(...data.map(d => d.value));
|
|
||||||
const min = Math.min(...data.map(d => d.value));
|
|
||||||
const range = max - min;
|
|
||||||
|
|
||||||
const points = data.map((point, index) => {
|
|
||||||
const x = (index / (data.length - 1)) * 100;
|
|
||||||
const y = range === 0 ? 50 : ((max - point.value) / range) * 100;
|
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(' ');
|
|
||||||
|
|
||||||
const colorClasses = {
|
|
||||||
blue: 'stroke-[var(--color-info)]',
|
|
||||||
green: 'stroke-[var(--color-success)]',
|
|
||||||
orange: 'stroke-[var(--color-primary)]',
|
|
||||||
red: 'stroke-[var(--color-error)]',
|
|
||||||
purple: 'stroke-[var(--color-info)]',
|
|
||||||
indigo: 'stroke-[var(--color-info)]',
|
|
||||||
teal: 'stroke-[var(--color-success)]'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-12 mt-2">
|
|
||||||
<svg
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
className="overflow-visible"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="2"
|
|
||||||
className={clsx(colorClasses[color as keyof typeof colorClasses] || colorClasses.blue)}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const KPIWidget: React.FC<KPIWidgetProps> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
trend,
|
|
||||||
icon,
|
|
||||||
color = 'blue',
|
|
||||||
variant = 'default',
|
|
||||||
sparklineData,
|
|
||||||
showSparkline = false,
|
|
||||||
thresholds,
|
|
||||||
status,
|
|
||||||
comparisonLabel,
|
|
||||||
contextInfo,
|
|
||||||
isLoading = false,
|
|
||||||
onRefresh,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
'aria-label': ariaLabel
|
|
||||||
}) => {
|
|
||||||
// Calculate trend if not provided
|
|
||||||
const calculatedTrend = useMemo(() => {
|
|
||||||
if (trend) return trend;
|
|
||||||
if (value.previous !== undefined) {
|
|
||||||
return calculateTrend(value.current, value.previous);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [trend, value.current, value.previous]);
|
|
||||||
|
|
||||||
// Determine status based on thresholds
|
|
||||||
const calculatedStatus = useMemo(() => {
|
|
||||||
if (status) return status;
|
|
||||||
if (!thresholds) return 'neutral';
|
|
||||||
|
|
||||||
const { current } = value;
|
|
||||||
if (current >= thresholds.excellent) return 'excellent';
|
|
||||||
if (current >= thresholds.good) return 'good';
|
|
||||||
if (current >= thresholds.warning) return 'warning';
|
|
||||||
return 'critical';
|
|
||||||
}, [status, thresholds, value.current]);
|
|
||||||
|
|
||||||
const statusStyles = getStatusColor(calculatedStatus);
|
|
||||||
const formattedValue = formatValue(value.current, value.format, value.prefix, value.suffix);
|
|
||||||
const formattedTarget = value.target ? formatValue(value.target, value.format, value.prefix, value.suffix) : null;
|
|
||||||
|
|
||||||
const colorClasses = {
|
|
||||||
blue: 'text-[var(--color-info)]',
|
|
||||||
green: 'text-[var(--color-success)]',
|
|
||||||
orange: 'text-[var(--color-primary)]',
|
|
||||||
red: 'text-[var(--color-error)]',
|
|
||||||
purple: 'text-[var(--color-info)]',
|
|
||||||
indigo: 'text-[var(--color-info)]',
|
|
||||||
teal: 'text-[var(--color-success)]'
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTrendIcon = (direction: KPITrend['direction']) => {
|
|
||||||
const iconClass = calculatedTrend?.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
|
||||||
|
|
||||||
switch (direction) {
|
|
||||||
case 'up':
|
|
||||||
return (
|
|
||||||
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 17l9.2-9.2M17 17V7H7" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case 'down':
|
|
||||||
return (
|
|
||||||
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 7l-9.2 9.2M7 7v10h10" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCompactVariant = () => (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{icon && (
|
|
||||||
<div className={clsx('p-2 rounded-lg', statusStyles.bg)}>
|
|
||||||
<div className={clsx('w-5 h-5', statusStyles.icon)}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{calculatedTrend && (
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{renderTrendIcon(calculatedTrend.direction)}
|
|
||||||
<span className={clsx(
|
|
||||||
'text-sm font-medium',
|
|
||||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
|
||||||
)}>
|
|
||||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDetailedVariant = () => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{icon && (
|
|
||||||
<div className={clsx('p-3 rounded-xl', statusStyles.bg)}>
|
|
||||||
<div className={clsx('w-6 h-6', statusStyles.icon)}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{calculatedStatus !== 'neutral' && (
|
|
||||||
<Badge variant={calculatedStatus === 'excellent' ? 'success' : calculatedStatus === 'critical' ? 'error' : 'warning'}>
|
|
||||||
{calculatedStatus === 'excellent' ? 'Excelente' :
|
|
||||||
calculatedStatus === 'good' ? 'Bueno' :
|
|
||||||
calculatedStatus === 'warning' ? 'Atención' : 'Crítico'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Value and trend */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-baseline space-x-3">
|
|
||||||
<span className="text-3xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
|
|
||||||
{calculatedTrend && (
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{renderTrendIcon(calculatedTrend.direction)}
|
|
||||||
<span className={clsx(
|
|
||||||
'text-sm font-medium',
|
|
||||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
|
||||||
)}>
|
|
||||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-[var(--text-tertiary)]">
|
|
||||||
{calculatedTrend.comparisonPeriod}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formattedTarget && (
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Objetivo: <span className="font-medium">{formattedTarget}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextInfo && (
|
|
||||||
<p className="text-sm text-[var(--text-tertiary)]">{contextInfo}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sparkline chart */}
|
|
||||||
{showSparkline && sparklineData && sparklineData.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">Tendencia últimos 7 días</p>
|
|
||||||
<SimpleSparkline data={sparklineData} color={color} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDefaultVariant = () => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{icon && (
|
|
||||||
<div className={clsx('w-8 h-8', colorClasses[color])}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-[var(--text-primary)]">{title}</h3>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-baseline space-x-2">
|
|
||||||
<span className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
|
|
||||||
{calculatedTrend && (
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{renderTrendIcon(calculatedTrend.direction)}
|
|
||||||
<span className={clsx(
|
|
||||||
'text-sm',
|
|
||||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
|
||||||
)}>
|
|
||||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{comparisonLabel && (
|
|
||||||
<p className="text-sm text-[var(--text-tertiary)]">{comparisonLabel}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case 'compact':
|
|
||||||
return renderCompactVariant();
|
|
||||||
case 'detailed':
|
|
||||||
return renderDetailedVariant();
|
|
||||||
case 'chart':
|
|
||||||
return renderDetailedVariant();
|
|
||||||
default:
|
|
||||||
return renderDefaultVariant();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
variant="metric"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
onClick={onClick}
|
|
||||||
interactive={!!onClick}
|
|
||||||
className={className}
|
|
||||||
aria-label={ariaLabel || `${title}: ${formattedValue}`}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
KPIWidget.displayName = 'KPIWidget';
|
|
||||||
|
|
||||||
export default KPIWidget;
|
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||||
|
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||||
|
import { Badge } from '../../ui/Badge';
|
||||||
|
import { Button } from '../../ui/Button';
|
||||||
|
import {
|
||||||
|
ShoppingCart,
|
||||||
|
Clock,
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
DollarSign,
|
||||||
|
Truck
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ProcurementItem {
|
||||||
|
id: string;
|
||||||
|
ingredient: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
supplier: string;
|
||||||
|
priority: 'urgent' | 'high' | 'medium' | 'low';
|
||||||
|
estimatedCost: number;
|
||||||
|
deliveryTime: string;
|
||||||
|
currentStock: number;
|
||||||
|
minStock: number;
|
||||||
|
plannedFor: string;
|
||||||
|
status: 'pending' | 'ordered' | 'in_transit' | 'delivered';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcurementPlansProps {
|
||||||
|
className?: string;
|
||||||
|
items?: ProcurementItem[];
|
||||||
|
onOrderItem?: (itemId: string) => void;
|
||||||
|
onViewDetails?: (itemId: string) => void;
|
||||||
|
onViewAllPlans?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProcurementPlansToday: React.FC<ProcurementPlansProps> = ({
|
||||||
|
className,
|
||||||
|
items = [],
|
||||||
|
onOrderItem,
|
||||||
|
onViewDetails,
|
||||||
|
onViewAllPlans
|
||||||
|
}) => {
|
||||||
|
const defaultItems: ProcurementItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
ingredient: 'Harina de Trigo',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'kg',
|
||||||
|
supplier: 'Molinos San José',
|
||||||
|
priority: 'urgent',
|
||||||
|
estimatedCost: 87.50,
|
||||||
|
deliveryTime: '10:00',
|
||||||
|
currentStock: 3,
|
||||||
|
minStock: 15,
|
||||||
|
plannedFor: '09:00',
|
||||||
|
status: 'pending',
|
||||||
|
notes: 'Stock crítico - necesario para producción matutina'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
ingredient: 'Levadura Fresca',
|
||||||
|
quantity: 5,
|
||||||
|
unit: 'kg',
|
||||||
|
supplier: 'Distribuidora Alba',
|
||||||
|
priority: 'urgent',
|
||||||
|
estimatedCost: 32.50,
|
||||||
|
deliveryTime: '11:30',
|
||||||
|
currentStock: 1,
|
||||||
|
minStock: 3,
|
||||||
|
plannedFor: '09:30',
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
ingredient: 'Mantequilla',
|
||||||
|
quantity: 15,
|
||||||
|
unit: 'kg',
|
||||||
|
supplier: 'Lácteos Premium',
|
||||||
|
priority: 'high',
|
||||||
|
estimatedCost: 105.00,
|
||||||
|
deliveryTime: '14:00',
|
||||||
|
currentStock: 8,
|
||||||
|
minStock: 12,
|
||||||
|
plannedFor: '10:00',
|
||||||
|
status: 'ordered'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
ingredient: 'Azúcar Blanco',
|
||||||
|
quantity: 25,
|
||||||
|
unit: 'kg',
|
||||||
|
supplier: 'Azucarera Local',
|
||||||
|
priority: 'medium',
|
||||||
|
estimatedCost: 62.50,
|
||||||
|
deliveryTime: '16:00',
|
||||||
|
currentStock: 18,
|
||||||
|
minStock: 20,
|
||||||
|
plannedFor: '11:00',
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayItems = items.length > 0 ? items : defaultItems;
|
||||||
|
|
||||||
|
const getItemStatusConfig = (item: ProcurementItem) => {
|
||||||
|
const baseConfig = {
|
||||||
|
isCritical: item.priority === 'urgent',
|
||||||
|
isHighlight: item.priority === 'high' || item.status === 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (item.status) {
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Pendiente',
|
||||||
|
icon: Clock
|
||||||
|
};
|
||||||
|
case 'ordered':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-info)',
|
||||||
|
text: 'Pedido',
|
||||||
|
icon: CheckCircle
|
||||||
|
};
|
||||||
|
case 'in_transit':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
text: 'En Camino',
|
||||||
|
icon: Truck
|
||||||
|
};
|
||||||
|
case 'delivered':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
text: 'Entregado',
|
||||||
|
icon: Package
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Pendiente',
|
||||||
|
icon: Clock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const urgentItems = displayItems.filter(item => item.priority === 'urgent').length;
|
||||||
|
const pendingItems = displayItems.filter(item => item.status === 'pending').length;
|
||||||
|
const totalValue = displayItems.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className} variant="elevated" padding="none">
|
||||||
|
<CardHeader padding="lg" divider>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Planes de Compra - Hoy
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Gestiona los pedidos programados para hoy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{urgentItems > 0 && (
|
||||||
|
<Badge variant="error" size="sm">
|
||||||
|
{urgentItems} urgentes
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
€{totalValue.toFixed(2)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody padding="none">
|
||||||
|
{displayItems.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--color-success)20' }}
|
||||||
|
>
|
||||||
|
<Package className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No hay compras programadas
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Todos los suministros están al día
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{displayItems.map((item) => {
|
||||||
|
const statusConfig = getItemStatusConfig(item);
|
||||||
|
const stockPercentage = Math.round((item.currentStock / item.minStock) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
statusIndicator={statusConfig}
|
||||||
|
title={item.ingredient}
|
||||||
|
subtitle={`${item.supplier} • ${item.quantity} ${item.unit}`}
|
||||||
|
primaryValue={`€${item.estimatedCost.toFixed(2)}`}
|
||||||
|
primaryValueLabel="COSTO"
|
||||||
|
secondaryInfo={{
|
||||||
|
label: 'Stock actual',
|
||||||
|
value: `${item.currentStock}/${item.minStock} ${item.unit} (${stockPercentage}%)`
|
||||||
|
}}
|
||||||
|
progress={item.currentStock < item.minStock ? {
|
||||||
|
label: `Stock: ${stockPercentage}% del mínimo`,
|
||||||
|
percentage: stockPercentage,
|
||||||
|
color: stockPercentage < 50 ? 'var(--color-error)' :
|
||||||
|
stockPercentage < 80 ? 'var(--color-warning)' : 'var(--color-success)'
|
||||||
|
} : undefined}
|
||||||
|
metadata={[
|
||||||
|
`📅 Pedido: ${item.plannedFor}`,
|
||||||
|
`🚚 Llegada: ${item.deliveryTime}`,
|
||||||
|
...(item.notes ? [`📋 ${item.notes}`] : [])
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
...(item.status === 'pending' ? [{
|
||||||
|
label: 'Realizar Pedido',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
variant: 'primary' as const,
|
||||||
|
onClick: () => onOrderItem?.(item.id),
|
||||||
|
priority: 'primary' as const
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
label: 'Ver Detalles',
|
||||||
|
icon: ChevronRight,
|
||||||
|
variant: 'outline' as const,
|
||||||
|
onClick: () => onViewDetails?.(item.id),
|
||||||
|
priority: 'secondary' as const
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
compact={true}
|
||||||
|
className="border-l-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="p-4 border-t"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border-primary)',
|
||||||
|
backgroundColor: 'var(--bg-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{pendingItems} pendientes de {displayItems.length} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onViewAllPlans}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Ver Todos los Planes
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProcurementPlansToday;
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||||
|
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||||
|
import { Badge } from '../../ui/Badge';
|
||||||
|
import { Button } from '../../ui/Button';
|
||||||
|
import {
|
||||||
|
Factory,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Thermometer,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
Timer,
|
||||||
|
Package,
|
||||||
|
Flame
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ProductionOrder {
|
||||||
|
id: string;
|
||||||
|
product: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
priority: 'urgent' | 'high' | 'medium' | 'low';
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'paused' | 'delayed';
|
||||||
|
startTime: string;
|
||||||
|
estimatedDuration: number; // in minutes
|
||||||
|
assignedBaker: string;
|
||||||
|
ovenNumber?: number;
|
||||||
|
temperature?: number;
|
||||||
|
progress: number; // 0-100
|
||||||
|
notes?: string;
|
||||||
|
recipe: string;
|
||||||
|
ingredients: Array<{
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
available: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionPlansProps {
|
||||||
|
className?: string;
|
||||||
|
orders?: ProductionOrder[];
|
||||||
|
onStartOrder?: (orderId: string) => void;
|
||||||
|
onPauseOrder?: (orderId: string) => void;
|
||||||
|
onViewDetails?: (orderId: string) => void;
|
||||||
|
onViewAllPlans?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
|
||||||
|
className,
|
||||||
|
orders = [],
|
||||||
|
onStartOrder,
|
||||||
|
onPauseOrder,
|
||||||
|
onViewDetails,
|
||||||
|
onViewAllPlans
|
||||||
|
}) => {
|
||||||
|
const defaultOrders: ProductionOrder[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
product: 'Pan de Molde Integral',
|
||||||
|
quantity: 20,
|
||||||
|
unit: 'unidades',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'in_progress',
|
||||||
|
startTime: '06:00',
|
||||||
|
estimatedDuration: 180,
|
||||||
|
assignedBaker: 'María González',
|
||||||
|
ovenNumber: 1,
|
||||||
|
temperature: 220,
|
||||||
|
progress: 65,
|
||||||
|
recipe: 'Receta Estándar Integral',
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Harina integral', quantity: 5, unit: 'kg', available: true },
|
||||||
|
{ name: 'Levadura', quantity: 0.5, unit: 'kg', available: true },
|
||||||
|
{ name: 'Sal', quantity: 0.2, unit: 'kg', available: true },
|
||||||
|
{ name: 'Agua', quantity: 3, unit: 'L', available: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
product: 'Croissants de Mantequilla',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'unidades',
|
||||||
|
priority: 'high',
|
||||||
|
status: 'pending',
|
||||||
|
startTime: '07:30',
|
||||||
|
estimatedDuration: 240,
|
||||||
|
assignedBaker: 'Carlos Rodríguez',
|
||||||
|
ovenNumber: 2,
|
||||||
|
temperature: 200,
|
||||||
|
progress: 0,
|
||||||
|
recipe: 'Croissant Francés',
|
||||||
|
notes: 'Masa preparada ayer, lista para horneado',
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Masa de croissant', quantity: 3, unit: 'kg', available: true },
|
||||||
|
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
|
||||||
|
{ name: 'Huevo', quantity: 6, unit: 'unidades', available: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
product: 'Baguettes Tradicionales',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'unidades',
|
||||||
|
priority: 'medium',
|
||||||
|
status: 'completed',
|
||||||
|
startTime: '05:00',
|
||||||
|
estimatedDuration: 240,
|
||||||
|
assignedBaker: 'Ana Martín',
|
||||||
|
ovenNumber: 3,
|
||||||
|
temperature: 240,
|
||||||
|
progress: 100,
|
||||||
|
recipe: 'Baguette Francesa',
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Harina blanca', quantity: 4, unit: 'kg', available: true },
|
||||||
|
{ name: 'Levadura', quantity: 0.3, unit: 'kg', available: true },
|
||||||
|
{ name: 'Sal', quantity: 0.15, unit: 'kg', available: true },
|
||||||
|
{ name: 'Agua', quantity: 2.5, unit: 'L', available: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
product: 'Magdalenas de Vainilla',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'unidades',
|
||||||
|
priority: 'medium',
|
||||||
|
status: 'delayed',
|
||||||
|
startTime: '09:00',
|
||||||
|
estimatedDuration: 90,
|
||||||
|
assignedBaker: 'Luis Fernández',
|
||||||
|
ovenNumber: 4,
|
||||||
|
temperature: 180,
|
||||||
|
progress: 0,
|
||||||
|
recipe: 'Magdalenas Clásicas',
|
||||||
|
notes: 'Retraso por falta de moldes',
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Harina', quantity: 2, unit: 'kg', available: true },
|
||||||
|
{ name: 'Azúcar', quantity: 1.5, unit: 'kg', available: true },
|
||||||
|
{ name: 'Huevos', quantity: 24, unit: 'unidades', available: true },
|
||||||
|
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
|
||||||
|
{ name: 'Vainilla', quantity: 50, unit: 'ml', available: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOrders = orders.length > 0 ? orders : defaultOrders;
|
||||||
|
|
||||||
|
const getOrderStatusConfig = (order: ProductionOrder) => {
|
||||||
|
const baseConfig = {
|
||||||
|
isCritical: order.status === 'delayed' || order.priority === 'urgent',
|
||||||
|
isHighlight: order.status === 'in_progress' || order.priority === 'high',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (order.status) {
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Pendiente',
|
||||||
|
icon: Clock
|
||||||
|
};
|
||||||
|
case 'in_progress':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-info)',
|
||||||
|
text: 'En Proceso',
|
||||||
|
icon: Play
|
||||||
|
};
|
||||||
|
case 'completed':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
text: 'Completado',
|
||||||
|
icon: CheckCircle
|
||||||
|
};
|
||||||
|
case 'paused':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Pausado',
|
||||||
|
icon: Pause
|
||||||
|
};
|
||||||
|
case 'delayed':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
text: 'Retrasado',
|
||||||
|
icon: AlertTriangle
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Pendiente',
|
||||||
|
icon: Clock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
return `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inProgressOrders = displayOrders.filter(order => order.status === 'in_progress').length;
|
||||||
|
const completedOrders = displayOrders.filter(order => order.status === 'completed').length;
|
||||||
|
const delayedOrders = displayOrders.filter(order => order.status === 'delayed').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className} variant="elevated" padding="none">
|
||||||
|
<CardHeader padding="lg" divider>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||||
|
>
|
||||||
|
<Factory className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Planes de Producción - Hoy
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Gestiona la producción programada para hoy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{delayedOrders > 0 && (
|
||||||
|
<Badge variant="error" size="sm">
|
||||||
|
{delayedOrders} retrasadas
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{inProgressOrders > 0 && (
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
{inProgressOrders} activas
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="success" size="sm">
|
||||||
|
{completedOrders} completadas
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody padding="none">
|
||||||
|
{displayOrders.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--color-success)20' }}
|
||||||
|
>
|
||||||
|
<Factory className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No hay producción programada
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Día libre de producción
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{displayOrders.map((order) => {
|
||||||
|
const statusConfig = getOrderStatusConfig(order);
|
||||||
|
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
|
||||||
|
const totalIngredients = order.ingredients.length;
|
||||||
|
const ingredientsReady = availableIngredients === totalIngredients;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
key={order.id}
|
||||||
|
id={order.id}
|
||||||
|
statusIndicator={statusConfig}
|
||||||
|
title={order.product}
|
||||||
|
subtitle={`${order.recipe} • ${order.quantity} ${order.unit}`}
|
||||||
|
primaryValue={`${order.progress}%`}
|
||||||
|
primaryValueLabel="PROGRESO"
|
||||||
|
secondaryInfo={{
|
||||||
|
label: 'Panadero asignado',
|
||||||
|
value: order.assignedBaker
|
||||||
|
}}
|
||||||
|
progress={order.status !== 'pending' ? {
|
||||||
|
label: `Progreso de producción`,
|
||||||
|
percentage: order.progress,
|
||||||
|
color: order.progress === 100 ? 'var(--color-success)' :
|
||||||
|
order.progress > 70 ? 'var(--color-info)' :
|
||||||
|
order.progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
|
||||||
|
} : undefined}
|
||||||
|
metadata={[
|
||||||
|
`⏰ Inicio: ${order.startTime}`,
|
||||||
|
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
|
||||||
|
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : []),
|
||||||
|
`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`,
|
||||||
|
...(order.notes ? [`📝 ${order.notes}`] : [])
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
...(order.status === 'pending' ? [{
|
||||||
|
label: 'Iniciar',
|
||||||
|
icon: Play,
|
||||||
|
variant: 'primary' as const,
|
||||||
|
onClick: () => onStartOrder?.(order.id),
|
||||||
|
priority: 'primary' as const
|
||||||
|
}] : []),
|
||||||
|
...(order.status === 'in_progress' ? [{
|
||||||
|
label: 'Pausar',
|
||||||
|
icon: Pause,
|
||||||
|
variant: 'outline' as const,
|
||||||
|
onClick: () => onPauseOrder?.(order.id),
|
||||||
|
priority: 'primary' as const,
|
||||||
|
destructive: true
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
label: 'Ver Detalles',
|
||||||
|
icon: ChevronRight,
|
||||||
|
variant: 'outline' as const,
|
||||||
|
onClick: () => onViewDetails?.(order.id),
|
||||||
|
priority: 'secondary' as const
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
compact={true}
|
||||||
|
className="border-l-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayOrders.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="p-4 border-t"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border-primary)',
|
||||||
|
backgroundColor: 'var(--bg-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{completedOrders} de {displayOrders.length} órdenes completadas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onViewAllPlans}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Ver Todos los Planes
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionPlansToday;
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import React, { KeyboardEvent, useCallback, useMemo } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Button, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export interface QuickAction {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
href?: string;
|
|
||||||
|
|
||||||
// Badge/notification support
|
|
||||||
badge?: string | number;
|
|
||||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
// Access control
|
|
||||||
permissions?: string[];
|
|
||||||
requiredRole?: string;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
disabledReason?: string;
|
|
||||||
|
|
||||||
// Styling
|
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger';
|
|
||||||
color?: string;
|
|
||||||
backgroundGradient?: string;
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
shortcut?: string;
|
|
||||||
|
|
||||||
// Priority for ordering
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuickActionsProps {
|
|
||||||
actions: QuickAction[];
|
|
||||||
|
|
||||||
// Layout configuration
|
|
||||||
columns?: 2 | 3 | 4 | 5 | 6;
|
|
||||||
gap?: 'sm' | 'md' | 'lg';
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
// Filtering and user context
|
|
||||||
userRole?: string;
|
|
||||||
userPermissions?: string[];
|
|
||||||
showDisabled?: boolean;
|
|
||||||
maxActions?: number;
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
onActionClick?: (action: QuickAction) => void;
|
|
||||||
onActionHover?: (action: QuickAction) => void;
|
|
||||||
|
|
||||||
// Accessibility
|
|
||||||
'aria-label'?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predefined bakery actions with Spanish context
|
|
||||||
export const BAKERY_QUICK_ACTIONS: QuickAction[] = [
|
|
||||||
{
|
|
||||||
id: 'new-order',
|
|
||||||
title: 'Nuevo Pedido',
|
|
||||||
description: 'Crear un nuevo pedido de cliente',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Nuevo pedido'),
|
|
||||||
variant: 'primary',
|
|
||||||
backgroundGradient: 'from-blue-500 to-blue-600',
|
|
||||||
priority: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'add-product',
|
|
||||||
title: 'Agregar Producto',
|
|
||||||
description: 'Añadir nuevo producto al inventario',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Agregar producto'),
|
|
||||||
variant: 'success',
|
|
||||||
backgroundGradient: 'from-green-500 to-green-600',
|
|
||||||
priority: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'view-inventory',
|
|
||||||
title: 'Ver Inventario',
|
|
||||||
description: 'Consultar stock y productos',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Ver inventario'),
|
|
||||||
variant: 'outline',
|
|
||||||
backgroundGradient: 'from-purple-500 to-purple-600',
|
|
||||||
priority: 3,
|
|
||||||
badge: '5',
|
|
||||||
badgeVariant: 'warning'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'production-batch',
|
|
||||||
title: 'Nueva Producción',
|
|
||||||
description: 'Programar lote de producción',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Nueva producción'),
|
|
||||||
variant: 'warning',
|
|
||||||
backgroundGradient: 'from-orange-500 to-orange-600',
|
|
||||||
priority: 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales-report',
|
|
||||||
title: 'Reporte Ventas',
|
|
||||||
description: 'Ver análisis de ventas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Reporte ventas'),
|
|
||||||
variant: 'secondary',
|
|
||||||
backgroundGradient: 'from-indigo-500 to-indigo-600',
|
|
||||||
priority: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'manage-suppliers',
|
|
||||||
title: 'Proveedores',
|
|
||||||
description: 'Gestionar proveedores',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Proveedores'),
|
|
||||||
variant: 'outline',
|
|
||||||
backgroundGradient: 'from-teal-500 to-teal-600',
|
|
||||||
priority: 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pos-system',
|
|
||||||
title: 'Sistema POS',
|
|
||||||
description: 'Punto de venta',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Sistema POS'),
|
|
||||||
variant: 'primary',
|
|
||||||
backgroundGradient: 'from-emerald-500 to-emerald-600',
|
|
||||||
priority: 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quality-control',
|
|
||||||
title: 'Control Calidad',
|
|
||||||
description: 'Verificación de calidad',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Control calidad'),
|
|
||||||
variant: 'success',
|
|
||||||
backgroundGradient: 'from-lime-500 to-lime-600',
|
|
||||||
priority: 8,
|
|
||||||
requiredRole: 'quality_manager'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const QuickActions: React.FC<QuickActionsProps> = ({
|
|
||||||
actions,
|
|
||||||
columns = 3,
|
|
||||||
gap = 'md',
|
|
||||||
size = 'md',
|
|
||||||
userRole,
|
|
||||||
userPermissions = [],
|
|
||||||
showDisabled = false,
|
|
||||||
maxActions,
|
|
||||||
onActionClick,
|
|
||||||
onActionHover,
|
|
||||||
'aria-label': ariaLabel = 'Acciones rápidas',
|
|
||||||
className
|
|
||||||
}) => {
|
|
||||||
// Filter and sort actions
|
|
||||||
const visibleActions = useMemo(() => {
|
|
||||||
let filteredActions = actions.filter(action => {
|
|
||||||
// Role-based filtering
|
|
||||||
if (action.requiredRole && userRole !== action.requiredRole) {
|
|
||||||
return showDisabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission-based filtering
|
|
||||||
if (action.permissions && action.permissions.length > 0) {
|
|
||||||
const hasPermission = action.permissions.some(perm =>
|
|
||||||
userPermissions.includes(perm)
|
|
||||||
);
|
|
||||||
if (!hasPermission) {
|
|
||||||
return showDisabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by priority
|
|
||||||
filteredActions.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
|
||||||
|
|
||||||
// Limit actions if specified
|
|
||||||
if (maxActions) {
|
|
||||||
filteredActions = filteredActions.slice(0, maxActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredActions;
|
|
||||||
}, [actions, userRole, userPermissions, showDisabled, maxActions]);
|
|
||||||
|
|
||||||
const handleActionClick = useCallback((action: QuickAction) => {
|
|
||||||
if (action.isDisabled) return;
|
|
||||||
|
|
||||||
onActionClick?.(action);
|
|
||||||
action.onClick();
|
|
||||||
}, [onActionClick]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, action: QuickAction) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleActionClick(action);
|
|
||||||
}
|
|
||||||
}, [handleActionClick]);
|
|
||||||
|
|
||||||
const gridClasses = {
|
|
||||||
2: 'grid-cols-2 sm:grid-cols-2',
|
|
||||||
3: 'grid-cols-2 sm:grid-cols-3',
|
|
||||||
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
||||||
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
|
||||||
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
|
||||||
};
|
|
||||||
|
|
||||||
const gapClasses = {
|
|
||||||
sm: 'gap-2',
|
|
||||||
md: 'gap-4',
|
|
||||||
lg: 'gap-6'
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'p-3 min-h-[80px]',
|
|
||||||
md: 'p-4 min-h-[100px]',
|
|
||||||
lg: 'p-6 min-h-[120px]'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (visibleActions.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
|
||||||
<p>No hay acciones disponibles</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'grid',
|
|
||||||
gridClasses[columns],
|
|
||||||
gapClasses[gap],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
role="grid"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
{visibleActions.map((action) => {
|
|
||||||
const isDisabled = action.isDisabled ||
|
|
||||||
(action.requiredRole && userRole !== action.requiredRole) ||
|
|
||||||
(action.permissions && !action.permissions.some(perm => userPermissions.includes(perm)));
|
|
||||||
|
|
||||||
const buttonClasses = clsx(
|
|
||||||
'relative group transition-all duration-200',
|
|
||||||
'border border-[var(--border-primary)] rounded-xl',
|
|
||||||
'flex flex-col items-center justify-center text-center',
|
|
||||||
'hover:shadow-lg hover:-translate-y-1',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
|
||||||
sizeClasses[size],
|
|
||||||
{
|
|
||||||
'bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)]': !action.backgroundGradient,
|
|
||||||
'bg-gradient-to-br text-white hover:opacity-90': action.backgroundGradient,
|
|
||||||
'opacity-50 cursor-not-allowed hover:transform-none hover:shadow-none': isDisabled,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const gradientStyle = action.backgroundGradient ? {
|
|
||||||
background: `linear-gradient(135deg, var(--tw-gradient-stops))`,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
className={buttonClasses}
|
|
||||||
onClick={() => handleActionClick(action)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, action)}
|
|
||||||
onMouseEnter={() => onActionHover?.(action)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
title={isDisabled ? action.disabledReason || 'Acción no disponible' : action.description}
|
|
||||||
aria-label={`${action.title}. ${action.description || ''}`}
|
|
||||||
style={gradientStyle}
|
|
||||||
>
|
|
||||||
{/* Badge */}
|
|
||||||
{action.badge && (
|
|
||||||
<Badge
|
|
||||||
variant={action.badgeVariant}
|
|
||||||
size="sm"
|
|
||||||
className="absolute -top-2 -right-2 z-10"
|
|
||||||
>
|
|
||||||
{action.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div className={clsx(
|
|
||||||
'mb-2 transition-transform duration-200',
|
|
||||||
'group-hover:scale-110',
|
|
||||||
{
|
|
||||||
'text-[var(--text-secondary)]': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<span className={clsx(
|
|
||||||
'font-medium text-sm leading-tight',
|
|
||||||
{
|
|
||||||
'text-[var(--text-primary)]': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{action.description && (
|
|
||||||
<span className={clsx(
|
|
||||||
'text-xs mt-1 opacity-75 leading-tight',
|
|
||||||
{
|
|
||||||
'text-[var(--text-secondary)]': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Keyboard shortcut */}
|
|
||||||
{action.shortcut && (
|
|
||||||
<div className={clsx(
|
|
||||||
'absolute bottom-1 right-1 text-xs opacity-50',
|
|
||||||
{
|
|
||||||
'text-[var(--text-tertiary)]': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.shortcut}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hover effect overlay */}
|
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 rounded-xl transition-all duration-200" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
QuickActions.displayName = 'QuickActions';
|
|
||||||
|
|
||||||
export default QuickActions;
|
|
||||||
307
frontend/src/components/domain/dashboard/RealTimeAlerts.tsx
Normal file
307
frontend/src/components/domain/dashboard/RealTimeAlerts.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||||
|
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||||
|
import { Badge } from '../../ui/Badge';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Bell
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: string;
|
||||||
|
type: 'critical' | 'warning' | 'info' | 'success';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: string;
|
||||||
|
actionRequired?: boolean;
|
||||||
|
resolved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealTimeAlertsProps {
|
||||||
|
className?: string;
|
||||||
|
maxAlerts?: number;
|
||||||
|
enableSSE?: boolean;
|
||||||
|
sseEndpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||||
|
className,
|
||||||
|
maxAlerts = 10,
|
||||||
|
enableSSE = true,
|
||||||
|
sseEndpoint = '/api/alerts/stream'
|
||||||
|
}) => {
|
||||||
|
const [alerts, setAlerts] = useState<Alert[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'critical',
|
||||||
|
title: 'Stock Crítico',
|
||||||
|
message: 'Levadura fresca: Solo quedan 2 unidades (mínimo: 5)',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: 'Inventario',
|
||||||
|
actionRequired: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Temperatura Horno',
|
||||||
|
message: 'Horno principal: Temperatura fuera del rango óptimo (185°C)',
|
||||||
|
timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||||
|
source: 'Producción',
|
||||||
|
actionRequired: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'info',
|
||||||
|
title: 'Nueva Orden',
|
||||||
|
message: 'Orden #1247: 50 croissants para las 14:00',
|
||||||
|
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||||
|
source: 'Ventas'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
if (enableSSE) {
|
||||||
|
eventSource = new EventSource(sseEndpoint);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const newAlert: Alert = JSON.parse(event.data);
|
||||||
|
setAlerts(prev => {
|
||||||
|
const updated = [newAlert, ...prev];
|
||||||
|
return updated.slice(0, maxAlerts);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing alert data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enableSSE, sseEndpoint, maxAlerts]);
|
||||||
|
|
||||||
|
const getAlertStatusConfig = (alert: Alert) => {
|
||||||
|
const baseConfig = {
|
||||||
|
isCritical: alert.type === 'critical',
|
||||||
|
isHighlight: alert.type === 'warning' || alert.actionRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (alert.type) {
|
||||||
|
case 'critical':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
text: 'Crítico',
|
||||||
|
icon: AlertTriangle
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
text: 'Advertencia',
|
||||||
|
icon: AlertCircle
|
||||||
|
};
|
||||||
|
case 'info':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-info)',
|
||||||
|
text: 'Información',
|
||||||
|
icon: Info
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
text: 'Éxito',
|
||||||
|
icon: CheckCircle
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
color: 'var(--color-info)',
|
||||||
|
text: 'Información',
|
||||||
|
icon: Info
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Ahora';
|
||||||
|
if (diffMins < 60) return `Hace ${diffMins}m`;
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissAlert = (alertId: string) => {
|
||||||
|
setAlerts(prev => prev.filter(alert => alert.id !== alertId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewAlert = (alertId: string) => {
|
||||||
|
console.log('Viewing alert details:', alertId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unresolvedAlerts = alerts.filter(alert => !alert.resolved);
|
||||||
|
const criticalCount = unresolvedAlerts.filter(alert => alert.type === 'critical').length;
|
||||||
|
const warningCount = unresolvedAlerts.filter(alert => alert.type === 'warning').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className} variant="elevated" padding="none">
|
||||||
|
<CardHeader padding="lg" divider>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Alertas en Tiempo Real
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{isConnected ? (
|
||||||
|
<Wifi className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-4 h-4" style={{ color: 'var(--color-error)' }} />
|
||||||
|
)}
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{isConnected ? 'Conectado' : 'Desconectado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<Badge variant="error" size="sm">
|
||||||
|
{criticalCount} críticas
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<Badge variant="warning" size="sm">
|
||||||
|
{warningCount} advertencias
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody padding="none">
|
||||||
|
{unresolvedAlerts.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--color-success)20' }}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No hay alertas activas
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Todo funciona correctamente en tu panadería
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{unresolvedAlerts.map((alert) => {
|
||||||
|
const statusConfig = getAlertStatusConfig(alert);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
key={alert.id}
|
||||||
|
id={alert.id}
|
||||||
|
statusIndicator={statusConfig}
|
||||||
|
title={alert.title}
|
||||||
|
subtitle={alert.message}
|
||||||
|
primaryValue={formatTimestamp(alert.timestamp)}
|
||||||
|
primaryValueLabel="TIEMPO"
|
||||||
|
secondaryInfo={{
|
||||||
|
label: 'Fuente',
|
||||||
|
value: alert.source
|
||||||
|
}}
|
||||||
|
metadata={alert.actionRequired ? ['🔥 Acción requerida'] : []}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Ver Detalles',
|
||||||
|
icon: Info,
|
||||||
|
variant: 'outline',
|
||||||
|
onClick: () => handleViewAlert(alert.id),
|
||||||
|
priority: 'primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Descartar',
|
||||||
|
icon: X,
|
||||||
|
variant: 'outline',
|
||||||
|
onClick: () => dismissAlert(alert.id),
|
||||||
|
priority: 'secondary',
|
||||||
|
destructive: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
compact={true}
|
||||||
|
className="border-l-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unresolvedAlerts.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="p-4 border-t text-center"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border-primary)',
|
||||||
|
backgroundColor: 'var(--bg-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{unresolvedAlerts.length} alertas activas •
|
||||||
|
<span className="ml-1" style={{ color: 'var(--color-primary)' }}>
|
||||||
|
Monitoreo automático habilitado
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealTimeAlerts;
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
import React, { useMemo, useState, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Avatar, Badge, Button } from '../../ui';
|
|
||||||
|
|
||||||
export interface ActivityUser {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityItem {
|
|
||||||
id: string;
|
|
||||||
type: ActivityType;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
timestamp: string;
|
|
||||||
user?: ActivityUser;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
status?: ActivityStatus;
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
// Navigation support
|
|
||||||
href?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
|
|
||||||
// Visual styling
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
color?: string;
|
|
||||||
priority?: ActivityPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityType {
|
|
||||||
ORDER = 'order',
|
|
||||||
PRODUCTION = 'production',
|
|
||||||
INVENTORY = 'inventory',
|
|
||||||
SALES = 'sales',
|
|
||||||
USER = 'user',
|
|
||||||
SYSTEM = 'system',
|
|
||||||
QUALITY = 'quality',
|
|
||||||
SUPPLIER = 'supplier',
|
|
||||||
FINANCE = 'finance',
|
|
||||||
ALERT = 'alert'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityStatus {
|
|
||||||
SUCCESS = 'success',
|
|
||||||
WARNING = 'warning',
|
|
||||||
ERROR = 'error',
|
|
||||||
INFO = 'info',
|
|
||||||
PENDING = 'pending',
|
|
||||||
IN_PROGRESS = 'in_progress',
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityPriority {
|
|
||||||
LOW = 'low',
|
|
||||||
MEDIUM = 'medium',
|
|
||||||
HIGH = 'high',
|
|
||||||
URGENT = 'urgent'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentActivityProps {
|
|
||||||
activities: ActivityItem[];
|
|
||||||
|
|
||||||
// Display configuration
|
|
||||||
maxItems?: number;
|
|
||||||
showTimestamp?: boolean;
|
|
||||||
showUserAvatar?: boolean;
|
|
||||||
showTypeIcons?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
|
|
||||||
// Filtering
|
|
||||||
allowFiltering?: boolean;
|
|
||||||
filterTypes?: ActivityType[];
|
|
||||||
defaultFilter?: ActivityType | 'all';
|
|
||||||
|
|
||||||
// Pagination and loading
|
|
||||||
hasMore?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
onActivityClick?: (activity: ActivityItem) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
|
|
||||||
// Accessibility and styling
|
|
||||||
className?: string;
|
|
||||||
'aria-label'?: string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spanish activity type labels and icons
|
|
||||||
const ACTIVITY_CONFIG = {
|
|
||||||
[ActivityType.ORDER]: {
|
|
||||||
label: 'Pedidos',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'blue',
|
|
||||||
bgColor: 'bg-[var(--color-info)]/10',
|
|
||||||
textColor: 'text-[var(--color-info)]',
|
|
||||||
borderColor: 'border-[var(--color-info)]/20'
|
|
||||||
},
|
|
||||||
[ActivityType.PRODUCTION]: {
|
|
||||||
label: 'Producción',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'orange',
|
|
||||||
bgColor: 'bg-[var(--color-primary)]/10',
|
|
||||||
textColor: 'text-[var(--color-primary)]',
|
|
||||||
borderColor: 'border-orange-200'
|
|
||||||
},
|
|
||||||
[ActivityType.INVENTORY]: {
|
|
||||||
label: 'Inventario',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'purple',
|
|
||||||
bgColor: 'bg-purple-100',
|
|
||||||
textColor: 'text-purple-600',
|
|
||||||
borderColor: 'border-purple-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SALES]: {
|
|
||||||
label: 'Ventas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-[var(--color-success)]/10',
|
|
||||||
textColor: 'text-[var(--color-success)]',
|
|
||||||
borderColor: 'border-green-200'
|
|
||||||
},
|
|
||||||
[ActivityType.USER]: {
|
|
||||||
label: 'Usuarios',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'indigo',
|
|
||||||
bgColor: 'bg-indigo-100',
|
|
||||||
textColor: 'text-indigo-600',
|
|
||||||
borderColor: 'border-indigo-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SYSTEM]: {
|
|
||||||
label: 'Sistema',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-[var(--bg-tertiary)]',
|
|
||||||
textColor: 'text-[var(--text-secondary)]',
|
|
||||||
borderColor: 'border-[var(--border-primary)]'
|
|
||||||
},
|
|
||||||
[ActivityType.QUALITY]: {
|
|
||||||
label: 'Calidad',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-[var(--color-success)]/10',
|
|
||||||
textColor: 'text-[var(--color-success)]',
|
|
||||||
borderColor: 'border-green-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SUPPLIER]: {
|
|
||||||
label: 'Proveedores',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'teal',
|
|
||||||
bgColor: 'bg-teal-100',
|
|
||||||
textColor: 'text-teal-600',
|
|
||||||
borderColor: 'border-teal-200'
|
|
||||||
},
|
|
||||||
[ActivityType.FINANCE]: {
|
|
||||||
label: 'Finanzas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'emerald',
|
|
||||||
bgColor: 'bg-emerald-100',
|
|
||||||
textColor: 'text-emerald-600',
|
|
||||||
borderColor: 'border-emerald-200'
|
|
||||||
},
|
|
||||||
[ActivityType.ALERT]: {
|
|
||||||
label: 'Alertas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'red',
|
|
||||||
bgColor: 'bg-[var(--color-error)]/10',
|
|
||||||
textColor: 'text-[var(--color-error)]',
|
|
||||||
borderColor: 'border-red-200'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
|
||||||
[ActivityStatus.SUCCESS]: { color: 'green', bgColor: 'bg-green-500' },
|
|
||||||
[ActivityStatus.WARNING]: { color: 'yellow', bgColor: 'bg-yellow-500' },
|
|
||||||
[ActivityStatus.ERROR]: { color: 'red', bgColor: 'bg-red-500' },
|
|
||||||
[ActivityStatus.INFO]: { color: 'blue', bgColor: 'bg-[var(--color-info)]/50' },
|
|
||||||
[ActivityStatus.PENDING]: { color: 'gray', bgColor: 'bg-[var(--bg-secondary)]0' },
|
|
||||||
[ActivityStatus.IN_PROGRESS]: { color: 'purple', bgColor: 'bg-purple-500' },
|
|
||||||
[ActivityStatus.CANCELLED]: { color: 'gray', bgColor: 'bg-gray-400' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: string): string => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const diffInMs = now.getTime() - date.getTime();
|
|
||||||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
|
|
||||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
||||||
const diffInDays = Math.floor(diffInHours / 24);
|
|
||||||
|
|
||||||
if (diffInMinutes < 1) {
|
|
||||||
return 'Ahora mismo';
|
|
||||||
} else if (diffInMinutes < 60) {
|
|
||||||
return `Hace ${diffInMinutes} min`;
|
|
||||||
} else if (diffInHours < 24) {
|
|
||||||
return `Hace ${diffInHours}h`;
|
|
||||||
} else if (diffInDays === 1) {
|
|
||||||
return 'Ayer';
|
|
||||||
} else if (diffInDays < 7) {
|
|
||||||
return `Hace ${diffInDays} días`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString('es-ES', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const RecentActivity: React.FC<RecentActivityProps> = ({
|
|
||||||
activities,
|
|
||||||
maxItems = 10,
|
|
||||||
showTimestamp = true,
|
|
||||||
showUserAvatar = true,
|
|
||||||
showTypeIcons = true,
|
|
||||||
compact = false,
|
|
||||||
allowFiltering = true,
|
|
||||||
filterTypes = Object.values(ActivityType),
|
|
||||||
defaultFilter = 'all',
|
|
||||||
hasMore = false,
|
|
||||||
isLoading = false,
|
|
||||||
onLoadMore,
|
|
||||||
onActivityClick,
|
|
||||||
onRefresh,
|
|
||||||
className,
|
|
||||||
'aria-label': ariaLabel = 'Actividad reciente',
|
|
||||||
emptyMessage = 'No hay actividad reciente'
|
|
||||||
}) => {
|
|
||||||
const [activeFilter, setActiveFilter] = useState<ActivityType | 'all'>(defaultFilter);
|
|
||||||
|
|
||||||
const filteredActivities = useMemo(() => {
|
|
||||||
let filtered = activities;
|
|
||||||
|
|
||||||
if (activeFilter !== 'all') {
|
|
||||||
filtered = activities.filter(activity => activity.type === activeFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.slice(0, maxItems);
|
|
||||||
}, [activities, activeFilter, maxItems]);
|
|
||||||
|
|
||||||
const handleActivityClick = useCallback((activity: ActivityItem) => {
|
|
||||||
onActivityClick?.(activity);
|
|
||||||
if (activity.onClick) {
|
|
||||||
activity.onClick();
|
|
||||||
}
|
|
||||||
}, [onActivityClick]);
|
|
||||||
|
|
||||||
const renderActivityItem = (activity: ActivityItem) => {
|
|
||||||
const config = ACTIVITY_CONFIG[activity.type] || {
|
|
||||||
label: 'Actividad',
|
|
||||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-[var(--bg-tertiary)]',
|
|
||||||
textColor: 'text-[var(--text-secondary)]',
|
|
||||||
borderColor: 'border-[var(--border-primary)]'
|
|
||||||
};
|
|
||||||
const statusConfig = activity.status ? STATUS_CONFIG[activity.status] : null;
|
|
||||||
|
|
||||||
const itemClasses = clsx(
|
|
||||||
'group relative flex items-start gap-3 p-3 rounded-lg transition-all duration-200',
|
|
||||||
'hover:bg-[var(--bg-secondary)] hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'cursor-pointer': activity.onClick || activity.href,
|
|
||||||
'p-2': compact,
|
|
||||||
'border-l-4': !compact,
|
|
||||||
[config.borderColor]: !compact
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={activity.id}
|
|
||||||
className={itemClasses}
|
|
||||||
onClick={activity.onClick || activity.href ? () => handleActivityClick(activity) : undefined}
|
|
||||||
role={activity.onClick || activity.href ? 'button' : undefined}
|
|
||||||
tabIndex={activity.onClick || activity.href ? 0 : undefined}
|
|
||||||
>
|
|
||||||
{/* Timeline indicator */}
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
{showTypeIcons && (
|
|
||||||
<div className={clsx(
|
|
||||||
'flex items-center justify-center rounded-full',
|
|
||||||
{
|
|
||||||
'w-8 h-8': !compact,
|
|
||||||
'w-6 h-6': compact
|
|
||||||
},
|
|
||||||
config.bgColor,
|
|
||||||
config.textColor
|
|
||||||
)}>
|
|
||||||
{activity.icon || config.icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status indicator */}
|
|
||||||
{activity.status && statusConfig && (
|
|
||||||
<div className={clsx(
|
|
||||||
'absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white',
|
|
||||||
statusConfig.bgColor
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className={clsx(
|
|
||||||
'font-medium text-[var(--text-primary)] truncate',
|
|
||||||
{
|
|
||||||
'text-sm': compact,
|
|
||||||
'text-base': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{activity.title}
|
|
||||||
</p>
|
|
||||||
<p className={clsx(
|
|
||||||
'text-[var(--text-secondary)] mt-1',
|
|
||||||
{
|
|
||||||
'text-xs line-clamp-1': compact,
|
|
||||||
'text-sm line-clamp-2': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{activity.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* User info */}
|
|
||||||
{activity.user && showUserAvatar && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Avatar
|
|
||||||
size="xs"
|
|
||||||
src={activity.user.avatar}
|
|
||||||
alt={activity.user.name}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{activity.user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
{showTimestamp && (
|
|
||||||
<time className={clsx(
|
|
||||||
'text-[var(--text-tertiary)] flex-shrink-0',
|
|
||||||
{
|
|
||||||
'text-xs': compact,
|
|
||||||
'text-sm': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{formatRelativeTime(activity.timestamp)}
|
|
||||||
</time>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activities.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
|
||||||
<div className="text-[var(--text-tertiary)] mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx('space-y-4', className)} aria-label={ariaLabel}>
|
|
||||||
{/* Filters */}
|
|
||||||
{allowFiltering && filterTypes.length > 1 && (
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-[var(--border-primary)]">
|
|
||||||
<Button
|
|
||||||
variant={activeFilter === 'all' ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveFilter('all')}
|
|
||||||
>
|
|
||||||
Todos
|
|
||||||
</Button>
|
|
||||||
{filterTypes.map((type) => {
|
|
||||||
const config = ACTIVITY_CONFIG[type] || {
|
|
||||||
label: 'Actividad',
|
|
||||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-[var(--bg-tertiary)]',
|
|
||||||
textColor: 'text-[var(--text-secondary)]',
|
|
||||||
borderColor: 'border-[var(--border-primary)]'
|
|
||||||
};
|
|
||||||
const count = activities.filter(a => a.type === type).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={type}
|
|
||||||
variant={activeFilter === type ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveFilter(type)}
|
|
||||||
leftIcon={config.icon}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
{count > 0 && (
|
|
||||||
<Badge size="xs" className="ml-1">
|
|
||||||
{count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{onRefresh && (
|
|
||||||
<div className="ml-auto">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRefresh}
|
|
||||||
aria-label="Actualizar actividad"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity list */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredActivities.map(renderActivityItem)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Load more */}
|
|
||||||
{hasMore && onLoadMore && !isLoading && (
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onLoadMore}
|
|
||||||
>
|
|
||||||
Ver más actividad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RecentActivity.displayName = 'RecentActivity';
|
|
||||||
|
|
||||||
export default RecentActivity;
|
|
||||||
@@ -1,350 +1,127 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TrendingUp, TrendingDown, AlertTriangle, CheckCircle, Clock, DollarSign } from 'lucide-react';
|
|
||||||
import { Card, Badge } from '../../components/ui';
|
|
||||||
import { PageHeader } from '../../components/layout';
|
import { PageHeader } from '../../components/layout';
|
||||||
import { DashboardCard, KPIWidget, QuickActions, RecentActivity, ActivityType, ActivityStatus } from '../../components/domain/dashboard';
|
import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
||||||
|
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||||
|
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
||||||
|
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Package,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
const kpiData = [
|
const criticalStats = [
|
||||||
{
|
{
|
||||||
title: 'Ventas Hoy',
|
title: 'Ventas Hoy',
|
||||||
value: {
|
value: '€1,247',
|
||||||
current: 1247,
|
icon: DollarSign,
|
||||||
previous: 1112,
|
variant: 'success' as const,
|
||||||
format: 'currency' as const,
|
|
||||||
prefix: '€'
|
|
||||||
},
|
|
||||||
trend: {
|
trend: {
|
||||||
direction: 'up' as const,
|
|
||||||
value: 12,
|
value: 12,
|
||||||
isPositive: true,
|
direction: 'up' as const,
|
||||||
comparisonPeriod: 'vs ayer'
|
label: '% vs ayer'
|
||||||
},
|
},
|
||||||
icon: <DollarSign className="w-5 h-5" />,
|
subtitle: '+€135 más que ayer'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Órdenes Pendientes',
|
title: 'Órdenes Pendientes',
|
||||||
value: {
|
value: '23',
|
||||||
current: 23,
|
icon: Clock,
|
||||||
previous: 24,
|
variant: 'warning' as const,
|
||||||
format: 'number' as const
|
|
||||||
},
|
|
||||||
trend: {
|
trend: {
|
||||||
|
value: 4,
|
||||||
direction: 'down' as const,
|
direction: 'down' as const,
|
||||||
value: 4.2,
|
label: '% vs ayer'
|
||||||
isPositive: false,
|
|
||||||
comparisonPeriod: 'vs ayer'
|
|
||||||
},
|
},
|
||||||
icon: <Clock className="w-5 h-5" />,
|
subtitle: 'Requieren atención'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Productos Vendidos',
|
title: 'Productos Vendidos',
|
||||||
value: {
|
value: '156',
|
||||||
current: 156,
|
icon: Package,
|
||||||
previous: 144,
|
variant: 'info' as const,
|
||||||
format: 'number' as const
|
|
||||||
},
|
|
||||||
trend: {
|
trend: {
|
||||||
|
value: 8,
|
||||||
direction: 'up' as const,
|
direction: 'up' as const,
|
||||||
value: 8.3,
|
label: '% vs ayer'
|
||||||
isPositive: true,
|
|
||||||
comparisonPeriod: 'vs ayer'
|
|
||||||
},
|
},
|
||||||
icon: <CheckCircle className="w-5 h-5" />,
|
subtitle: '+12 unidades más'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Stock Crítico',
|
title: 'Stock Crítico',
|
||||||
value: {
|
value: '4',
|
||||||
current: 4,
|
icon: AlertTriangle,
|
||||||
previous: 2,
|
variant: 'error' as const,
|
||||||
format: 'number' as const
|
|
||||||
},
|
|
||||||
trend: {
|
trend: {
|
||||||
direction: 'up' as const,
|
|
||||||
value: 100,
|
value: 100,
|
||||||
isPositive: false,
|
direction: 'up' as const,
|
||||||
comparisonPeriod: 'vs ayer'
|
label: '% vs ayer'
|
||||||
},
|
|
||||||
status: 'warning' as const,
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
},
|
},
|
||||||
|
subtitle: 'Acción requerida'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const quickActions = [
|
const handleOrderItem = (itemId: string) => {
|
||||||
{
|
console.log('Ordering item:', itemId);
|
||||||
id: 'production',
|
|
||||||
title: 'Nueva Orden de Producción',
|
|
||||||
description: 'Crear nueva orden de producción',
|
|
||||||
icon: <TrendingUp className="w-6 h-6" />,
|
|
||||||
onClick: () => window.location.href = '/app/operations/production',
|
|
||||||
href: '/app/operations/production'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inventory',
|
|
||||||
title: 'Gestionar Inventario',
|
|
||||||
description: 'Administrar stock de productos',
|
|
||||||
icon: <CheckCircle className="w-6 h-6" />,
|
|
||||||
onClick: () => window.location.href = '/app/operations/inventory',
|
|
||||||
href: '/app/operations/inventory'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales',
|
|
||||||
title: 'Ver Ventas',
|
|
||||||
description: 'Analizar ventas y reportes',
|
|
||||||
icon: <DollarSign className="w-6 h-6" />,
|
|
||||||
onClick: () => window.location.href = '/app/analytics/sales',
|
|
||||||
href: '/app/analytics/sales'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
title: 'Configuración',
|
|
||||||
description: 'Ajustar configuración del sistema',
|
|
||||||
icon: <AlertTriangle className="w-6 h-6" />,
|
|
||||||
onClick: () => window.location.href = '/app/settings',
|
|
||||||
href: '/app/settings'
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const recentActivities = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: ActivityType.PRODUCTION,
|
|
||||||
title: 'Orden de producción completada',
|
|
||||||
description: 'Pan de Molde Integral - 20 unidades',
|
|
||||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
||||||
status: ActivityStatus.SUCCESS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: ActivityType.INVENTORY,
|
|
||||||
title: 'Stock bajo detectado',
|
|
||||||
description: 'Levadura fresca necesita reposición',
|
|
||||||
timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
|
||||||
status: ActivityStatus.WARNING,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: ActivityType.SALES,
|
|
||||||
title: 'Venta registrada',
|
|
||||||
description: '€45.50 - Croissants y café',
|
|
||||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
|
||||||
status: ActivityStatus.INFO,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const productionStatus = {
|
|
||||||
today: {
|
|
||||||
target: 150,
|
|
||||||
completed: 95,
|
|
||||||
inProgress: 18,
|
|
||||||
pending: 37,
|
|
||||||
},
|
|
||||||
efficiency: 85,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const salesData = {
|
const handleStartOrder = (orderId: string) => {
|
||||||
today: 1247,
|
console.log('Starting production order:', orderId);
|
||||||
yesterday: 1112,
|
|
||||||
thisWeek: 8934,
|
|
||||||
thisMonth: 35678,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const inventoryAlerts = [
|
const handlePauseOrder = (orderId: string) => {
|
||||||
{ item: 'Levadura Fresca', current: 2, min: 5, status: 'critical' },
|
console.log('Pausing production order:', orderId);
|
||||||
{ item: 'Harina Integral', current: 8, min: 10, status: 'low' },
|
};
|
||||||
{ item: 'Mantequilla', current: 15, min: 20, status: 'low' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const topProducts = [
|
const handleViewDetails = (id: string) => {
|
||||||
{ name: 'Pan de Molde', sold: 45, revenue: 202.50 },
|
console.log('Viewing details for:', id);
|
||||||
{ name: 'Croissants', sold: 32, revenue: 192.00 },
|
};
|
||||||
{ name: 'Baguettes', sold: 28, revenue: 84.00 },
|
|
||||||
{ name: 'Magdalenas', sold: 24, revenue: 72.00 },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const handleViewAllPlans = () => {
|
||||||
|
console.log('Viewing all plans');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Panel de Control"
|
title="Panel de Control"
|
||||||
description="Vista general de tu panadería"
|
description="Vista general de tu panadería"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* Critical Metrics using StatsGrid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<StatsGrid
|
||||||
{kpiData.map((kpi, index) => (
|
stats={criticalStats}
|
||||||
<KPIWidget
|
columns={4}
|
||||||
key={index}
|
title="Métricas Críticas"
|
||||||
title={kpi.title}
|
description="Los datos más importantes para la gestión diaria de tu panadería"
|
||||||
value={kpi.value}
|
gap="lg"
|
||||||
trend={kpi.trend}
|
className="mb-6"
|
||||||
icon={kpi.icon}
|
|
||||||
status={kpi.status}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* Full width blocks - one after another */}
|
||||||
{/* Production Status */}
|
<div className="space-y-6">
|
||||||
<Card className="p-6">
|
{/* 1. Real-time alerts block */}
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Estado de Producción</h3>
|
<RealTimeAlerts />
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* 2. Procurement plans block */}
|
||||||
<div className="flex justify-between items-center">
|
<ProcurementPlansToday
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Progreso del Día</span>
|
onOrderItem={handleOrderItem}
|
||||||
<span className="text-sm font-medium">
|
onViewDetails={handleViewDetails}
|
||||||
{productionStatus.today.completed} / {productionStatus.today.target}
|
onViewAllPlans={handleViewAllPlans}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
{/* 3. Production plans block */}
|
||||||
<div
|
<ProductionPlansToday
|
||||||
className="bg-[var(--color-info)] h-2 rounded-full"
|
onStartOrder={handleStartOrder}
|
||||||
style={{
|
onPauseOrder={handlePauseOrder}
|
||||||
width: `${(productionStatus.today.completed / productionStatus.today.target) * 100}%`
|
onViewDetails={handleViewDetails}
|
||||||
}}
|
onViewAllPlans={handleViewAllPlans}
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-success)]">{productionStatus.today.completed}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Completado</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{productionStatus.today.inProgress}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">En Proceso</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{productionStatus.today.pending}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Pendiente</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Eficiencia</span>
|
|
||||||
<span className="text-sm font-medium text-purple-600">{productionStatus.efficiency}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sales Summary */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Ventas</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Hoy</span>
|
|
||||||
<span className="text-lg font-semibold text-[var(--color-success)]">€{salesData.today.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Ayer</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-sm font-medium mr-2">€{salesData.yesterday.toLocaleString()}</span>
|
|
||||||
{salesData.today > salesData.yesterday ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-[var(--color-success)]" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-[var(--color-error)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Esta Semana</span>
|
|
||||||
<span className="text-sm font-medium">€{salesData.thisWeek.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Este Mes</span>
|
|
||||||
<span className="text-sm font-medium">€{salesData.thisMonth.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Crecimiento vs ayer</p>
|
|
||||||
<p className="text-lg font-semibold text-[var(--color-success)]">
|
|
||||||
+{(((salesData.today - salesData.yesterday) / salesData.yesterday) * 100).toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Inventory Alerts */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas de Inventario</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{inventoryAlerts.map((alert, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--color-error)]/10 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">{alert.item}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Stock: {alert.current} / Mín: {alert.min}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={alert.status === 'critical' ? 'red' : 'yellow'}>
|
|
||||||
{alert.status === 'critical' ? 'Crítico' : 'Bajo'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
|
||||||
Ver Todo el Inventario →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Top Products */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Productos Más Vendidos</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{topProducts.map((product, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)] w-6">{index + 1}.</span>
|
|
||||||
<span className="text-sm text-[var(--text-primary)]">{product.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">{product.sold} unidades</p>
|
|
||||||
<p className="text-xs text-[var(--color-success)]">€{product.revenue.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
|
||||||
Ver Análisis Completo →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Actividad Reciente</h3>
|
|
||||||
|
|
||||||
<RecentActivity activities={recentActivities} />
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
|
||||||
Ver Toda la Actividad →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Acciones Rápidas</h3>
|
|
||||||
<QuickActions actions={quickActions} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user