Improve the frontend

This commit is contained in:
Urtzi Alfaro
2025-10-21 19:50:07 +02:00
parent 05da20357d
commit 8d30172483
105 changed files with 14699 additions and 4630 deletions

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Check, Trash2, Clock, X } from 'lucide-react';
import { Button } from '../../ui/Button';
import AlertSnoozeMenu from './AlertSnoozeMenu';
export interface AlertBulkActionsProps {
selectedCount: number;
onMarkAsRead: () => void;
onRemove: () => void;
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onDeselectAll: () => void;
onSelectAll: () => void;
totalCount: number;
}
const AlertBulkActions: React.FC<AlertBulkActionsProps> = ({
selectedCount,
onMarkAsRead,
onRemove,
onSnooze,
onDeselectAll,
onSelectAll,
totalCount,
}) => {
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
if (selectedCount === 0) {
return null;
}
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
onSnooze(duration);
setShowSnoozeMenu(false);
};
const allSelected = selectedCount === totalCount;
return (
<div className="sticky top-0 z-20 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-3 rounded-xl shadow-xl flex items-center justify-between gap-3 animate-in slide-in-from-top-2 duration-300">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg">
<span className="text-sm font-bold">{selectedCount}</span>
<span className="text-xs font-medium opacity-90">
{selectedCount === 1 ? 'seleccionado' : 'seleccionados'}
</span>
</div>
{!allSelected && totalCount > selectedCount && (
<button
onClick={onSelectAll}
className="text-sm font-medium hover:underline opacity-90 hover:opacity-100 transition-opacity whitespace-nowrap"
aria-label={`Select all ${totalCount} alerts`}
>
Seleccionar todos ({totalCount})
</button>
)}
</div>
<div className="flex items-center gap-2 relative flex-shrink-0">
{/* Quick Actions */}
<Button
variant="outline"
size="sm"
onClick={onMarkAsRead}
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
aria-label="Mark all selected as read"
>
<Check className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Marcar leídos</span>
</Button>
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
aria-label="Snooze selected alerts"
>
<Clock className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Posponer</span>
</Button>
{showSnoozeMenu && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowSnoozeMenu(false)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full mt-2 z-30">
<AlertSnoozeMenu
onSnooze={handleSnooze}
onCancel={() => setShowSnoozeMenu(false)}
/>
</div>
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={onRemove}
className="bg-red-500/25 text-white border-red-300/40 hover:bg-red-500/40 backdrop-blur-sm h-9 px-3"
aria-label="Delete selected alerts"
>
<Trash2 className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Eliminar</span>
</Button>
{/* Close button */}
<button
onClick={onDeselectAll}
className="ml-1 p-2 hover:bg-white/15 rounded-lg transition-colors"
aria-label="Deselect all"
title="Cerrar selección"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default AlertBulkActions;

View File

@@ -0,0 +1,446 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
Check,
Trash2,
Clock,
MoreVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import type { NotificationData } from '../../../hooks/useNotifications';
import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers';
import AlertContextActions from './AlertContextActions';
import AlertSnoozeMenu from './AlertSnoozeMenu';
export interface AlertCardProps {
alert: NotificationData;
isExpanded: boolean;
isSelected: boolean;
isSnoozed: boolean;
snoozedUntil?: number;
onToggleExpand: () => void;
onToggleSelect: () => void;
onMarkAsRead: () => void;
onRemove: () => void;
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onUnsnooze: () => void;
showCheckbox?: boolean;
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
return Info;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'urgent':
return 'var(--color-error)';
case 'high':
return 'var(--color-warning)';
case 'medium':
return 'var(--color-info)';
case 'low':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
};
const getSeverityBadge = (severity: string): 'error' | 'warning' | 'info' | 'success' => {
switch (severity) {
case 'urgent':
return 'error';
case 'high':
return 'warning';
case 'medium':
return 'info';
case 'low':
return 'success';
default:
return 'info';
}
};
const formatTimestamp = (timestamp: string, t: any) => {
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 t('dashboard:alerts.time.now', 'Ahora');
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
? t('dashboard:alerts.time.yesterday', 'Ayer')
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
const AlertCard: React.FC<AlertCardProps> = ({
alert,
isExpanded,
isSelected,
isSnoozed,
snoozedUntil,
onToggleExpand,
onToggleSelect,
onMarkAsRead,
onRemove,
onSnooze,
onUnsnooze,
showCheckbox = false,
}) => {
const { t } = useTranslation(['dashboard']);
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
const [showActions, setShowActions] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const SeverityIcon = getSeverityIcon(alert.severity);
const severityColor = getSeverityColor(alert.severity);
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
onSnooze(duration);
setShowSnoozeMenu(false);
};
const category = categorizeAlert(alert);
return (
<div
className={`
rounded-lg transition-all duration-200 relative overflow-hidden
${isExpanded ? 'shadow-md' : 'hover:shadow-md'}
${isSelected ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''}
${isSnoozed ? 'opacity-75' : ''}
`}
style={{
backgroundColor: 'var(--bg-primary)',
border: '1px solid var(--border-primary)',
...(isExpanded && {
backgroundColor: 'var(--bg-secondary)',
}),
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Left severity accent border */}
<div
className="absolute left-0 top-0 bottom-0 w-1"
style={{ backgroundColor: severityColor }}
/>
{/* Compact Card Header */}
<div className="flex items-start gap-3 p-4 pl-5">
{/* Checkbox for selection */}
{showCheckbox && (
<div className="flex-shrink-0 pt-0.5">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer"
aria-label={`Select alert: ${alert.title}`}
/>
</div>
)}
{/* Severity Icon */}
<div
className="flex-shrink-0 p-2 rounded-lg cursor-pointer hover:scale-105 transition-transform"
style={{ backgroundColor: severityColor + '15' }}
onClick={onToggleExpand}
aria-label="Toggle alert details"
>
<SeverityIcon
className="w-5 h-5"
style={{ color: severityColor }}
/>
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
{/* Title and Status */}
<div className="flex items-start justify-between gap-3 mb-1.5">
<div className="flex-1 min-w-0">
<h4 className="text-base font-semibold leading-snug mb-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
</h4>
<div className="flex items-center gap-2 flex-wrap">
{/* Single primary severity badge */}
<Badge variant={getSeverityBadge(alert.severity)} size="sm" className="font-semibold px-2.5 py-1 min-h-[1.375rem]">
{t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())}
</Badge>
{/* Unread indicator */}
{!alert.read && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/20 min-h-[1.375rem]" style={{ color: 'var(--color-info)' }}>
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse flex-shrink-0" />
{t('dashboard:alerts.status.new', 'Nuevo')}
</span>
)}
{/* Snoozed indicator */}
{isSnoozed && snoozedUntil && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-gray-50 dark:bg-gray-800 min-h-[1.375rem]" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="whitespace-nowrap">{getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))}</span>
</span>
)}
</div>
</div>
{/* Timestamp */}
<span className="text-xs font-medium flex-shrink-0 pt-0.5" style={{ color: 'var(--text-secondary)' }}>
{formatTimestamp(alert.timestamp, t)}
</span>
</div>
{/* Preview message when collapsed */}
{!isExpanded && alert.message && (
<p
className="text-sm leading-relaxed mt-2 overflow-hidden"
style={{
color: 'var(--text-secondary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{alert.message}
</p>
)}
</div>
{/* Actions - shown on hover or when expanded */}
<div className={`flex-shrink-0 flex items-center gap-1 transition-opacity ${isHovered || isExpanded || showActions ? 'opacity-100' : 'opacity-0'}`}>
{/* Quick action buttons */}
{!alert.read && !isExpanded && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
title={t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
aria-label="Mark as read"
>
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowActions(!showActions);
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
aria-label="More actions"
title="More actions"
>
<MoreVertical className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
aria-label={isExpanded ? "Collapse" : "Expand"}
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</div>
{/* Quick Actions Menu - Better positioning */}
{showActions && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowActions(false)}
aria-hidden="true"
/>
<div className="absolute right-3 top-16 z-30 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-1 min-w-[180px]">
{!alert.read && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
Marcar como leído
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowSnoozeMenu(true);
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" />
{isSnoozed ? 'Cambiar tiempo' : 'Posponer'}
</button>
{isSnoozed && (
<button
onClick={(e) => {
e.stopPropagation();
onUnsnooze();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" />
Reactivar ahora
</button>
)}
<div className="my-1 border-t border-[var(--border-primary)]" />
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Eliminar
</button>
</div>
</>
)}
{/* Snooze Menu - Better positioning */}
{showSnoozeMenu && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowSnoozeMenu(false)}
aria-hidden="true"
/>
<div className="absolute right-3 top-16 z-30">
<AlertSnoozeMenu
onSnooze={handleSnooze}
onCancel={() => setShowSnoozeMenu(false)}
/>
</div>
</>
)}
{/* Expanded Details */}
{isExpanded && (
<div className="px-5 pb-4 border-t pt-4" style={{ borderColor: 'var(--border-primary)' }}>
{/* Full Message */}
<div className="mb-4">
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{alert.message}
</p>
</div>
{/* Metadata */}
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
<div className="mb-4 p-3 rounded-lg border" style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border-primary)'
}}>
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
</p>
<div className="text-sm space-y-2" style={{ color: 'var(--text-secondary)' }}>
{Object.entries(alert.metadata).map(([key, value]) => (
<div key={key} className="flex justify-between gap-4">
<span className="font-medium capitalize text-[var(--text-primary)]">{key.replace(/_/g, ' ')}:</span>
<span className="text-right">{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Contextual Actions */}
<div className="mb-4">
<AlertContextActions alert={alert} />
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-wrap">
{!alert.read && (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
}}
className="h-9 px-4 text-sm font-medium"
>
<Check className="w-4 h-4 mr-2" />
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowSnoozeMenu(true);
}}
className="h-9 px-4 text-sm font-medium"
>
<Clock className="w-4 h-4 mr-2" />
{isSnoozed ? 'Cambiar' : 'Posponer'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-9 px-4 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4 mr-2" />
{t('dashboard:alerts.remove', 'Eliminar')}
</Button>
</div>
</div>
)}
</div>
);
};
export default AlertCard;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Button } from '../../ui/Button';
import type { NotificationData } from '../../../hooks/useNotifications';
import { useAlertActions } from '../../../hooks/useAlertActions';
export interface AlertContextActionsProps {
alert: NotificationData;
}
const AlertContextActions: React.FC<AlertContextActionsProps> = ({ alert }) => {
const { getActions, executeAction } = useAlertActions();
const actions = getActions(alert);
if (actions.length === 0) {
return null;
}
return (
<div className="mb-4">
<p className="text-xs font-semibold mb-2 uppercase tracking-wide text-[var(--text-primary)]">
Acciones Recomendadas
</p>
<div className="flex flex-wrap gap-2">
{actions.map((action, index) => {
const variantMap: Record<string, 'primary' | 'secondary' | 'outline'> = {
primary: 'primary',
secondary: 'secondary',
outline: 'outline',
};
return (
<Button
key={index}
variant={variantMap[action.variant] || 'outline'}
size="sm"
onClick={() => executeAction(alert, action)}
className="flex items-center gap-2"
>
<span>{action.icon}</span>
<span>{action.label}</span>
</Button>
);
})}
</div>
</div>
);
};
export default AlertContextActions;

View File

@@ -0,0 +1,306 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, X, Filter, ChevronDown } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers';
import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers';
export interface AlertFiltersProps {
selectedSeverities: AlertSeverity[];
selectedCategories: AlertCategory[];
selectedTimeRange: TimeGroup | 'all';
searchQuery: string;
showSnoozed: boolean;
onToggleSeverity: (severity: AlertSeverity) => void;
onToggleCategory: (category: AlertCategory) => void;
onSetTimeRange: (range: TimeGroup | 'all') => void;
onSearchChange: (query: string) => void;
onToggleShowSnoozed: () => void;
onClearFilters: () => void;
hasActiveFilters: boolean;
activeFilterCount: number;
}
const SEVERITY_CONFIG: Record<AlertSeverity, { label: string; color: string; variant: 'error' | 'warning' | 'info' | 'success' }> = {
urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' },
high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' },
medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' },
low: { label: 'Baja', color: 'bg-green-500', variant: 'success' },
};
const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'today', label: 'Hoy' },
{ value: 'yesterday', label: 'Ayer' },
{ value: 'this_week', label: 'Esta semana' },
{ value: 'older', label: 'Anteriores' },
];
const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers'];
const AlertFilters: React.FC<AlertFiltersProps> = ({
selectedSeverities,
selectedCategories,
selectedTimeRange,
searchQuery,
showSnoozed,
onToggleSeverity,
onToggleCategory,
onSetTimeRange,
onSearchChange,
onToggleShowSnoozed,
onClearFilters,
hasActiveFilters,
activeFilterCount,
}) => {
const { t, i18n } = useTranslation(['dashboard']);
// Start collapsed by default for cleaner UI
const [showFilters, setShowFilters] = useState(false);
return (
<div className="space-y-3">
{/* Search and Filter Toggle */}
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Input
type="text"
placeholder={t('dashboard:alerts.filters.search_placeholder', 'Buscar alertas...')}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-4 h-4" />}
rightIcon={
searchQuery ? (
<button
onClick={() => onSearchChange('')}
className="p-1 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
aria-label="Clear search"
>
<X className="w-3 h-3" />
</button>
) : undefined
}
className="pr-8 h-10"
/>
</div>
<Button
variant={showFilters || hasActiveFilters ? 'primary' : 'outline'}
size="md"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 relative h-10 px-4"
aria-expanded={showFilters}
aria-label="Toggle filters"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline font-medium">Filtros</span>
{activeFilterCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-error)] text-white text-xs font-bold rounded-full flex items-center justify-center">
{activeFilterCount}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${showFilters ? 'rotate-180' : ''}`} />
</Button>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-500 hover:text-red-600 h-10 px-3"
title="Clear all filters"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
<span className="sr-only">Limpiar filtros</span>
</Button>
)}
</div>
{/* Expandable Filters Panel - Animated */}
{showFilters && (
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Severity Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.severity', 'Severidad')}
</label>
<div className="flex flex-wrap gap-2">
{(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => {
const config = SEVERITY_CONFIG[severity];
const isSelected = selectedSeverities.includes(severity);
return (
<button
key={severity}
onClick={() => onToggleSeverity(severity)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSelected
? 'ring-2 ring-[var(--color-primary)] ring-offset-2 ring-offset-[var(--bg-secondary)] scale-105'
: 'opacity-70 hover:opacity-100 hover:scale-105'
}
`}
aria-pressed={isSelected}
>
<Badge variant={config.variant} size="sm" className="pointer-events-none">
{config.label}
</Badge>
</button>
);
})}
</div>
</div>
{/* Category Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.category', 'Categoría')}
</label>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((category) => {
const isSelected = selectedCategories.includes(category);
return (
<button
key={category}
onClick={() => onToggleCategory(category)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all border-2
${isSelected
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)] scale-105'
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:text-[var(--text-primary)] hover:scale-105'
}
`}
aria-pressed={isSelected}
>
<span className="mr-1.5">{getCategoryIcon(category)}</span>
{getCategoryName(category, i18n.language)}
</button>
);
})}
</div>
</div>
{/* Time Range Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.time_range', 'Periodo')}
</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{TIME_RANGES.map((range) => {
const isSelected = selectedTimeRange === range.value;
return (
<button
key={range.value}
onClick={() => onSetTimeRange(range.value)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSelected
? 'bg-[var(--color-primary)] text-white shadow-md scale-105'
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:scale-105'
}
`}
aria-pressed={isSelected}
>
{range.label}
</button>
);
})}
</div>
</div>
{/* Show Snoozed Toggle */}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
<label htmlFor="show-snoozed-toggle" className="text-sm font-medium text-[var(--text-primary)] cursor-pointer">
{t('dashboard:alerts.filters.show_snoozed', 'Mostrar pospuestos')}
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
id="show-snoozed-toggle"
type="checkbox"
checked={showSnoozed}
onChange={onToggleShowSnoozed}
className="sr-only peer"
aria-label="Toggle show snoozed alerts"
/>
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--color-primary)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
</div>
)}
{/* Active Filters Summary - Chips */}
{hasActiveFilters && !showFilters && (
<div className="flex flex-wrap gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<span className="text-xs font-medium text-[var(--text-secondary)] self-center">
Filtros activos:
</span>
{selectedSeverities.map((severity) => (
<button
key={severity}
onClick={() => onToggleSeverity(severity)}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label={`Remove ${severity} filter`}
>
<Badge
variant={SEVERITY_CONFIG[severity].variant}
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{SEVERITY_CONFIG[severity].label}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
))}
{selectedCategories.map((category) => (
<button
key={category}
onClick={() => onToggleCategory(category)}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label={`Remove ${category} filter`}
>
<Badge
variant="secondary"
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{getCategoryIcon(category)} {getCategoryName(category, i18n.language)}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
))}
{selectedTimeRange !== 'all' && (
<button
onClick={() => onSetTimeRange('all')}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label="Remove time range filter"
>
<Badge
variant="info"
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{TIME_RANGES.find(r => r.value === selectedTimeRange)?.label}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
)}
</div>
)}
</div>
);
};
export default AlertFilters;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import type { AlertGroup } from '../../../utils/alertHelpers';
export interface AlertGroupHeaderProps {
group: AlertGroup;
isCollapsed: boolean;
onToggleCollapse: () => void;
}
const SEVERITY_COLORS: Record<string, string> = {
urgent: 'text-red-600 bg-red-50 border-red-200',
high: 'text-orange-600 bg-orange-50 border-orange-200',
medium: 'text-blue-600 bg-blue-50 border-blue-200',
low: 'text-green-600 bg-green-50 border-green-200',
};
const SEVERITY_BADGE_VARIANTS: Record<string, 'error' | 'warning' | 'info' | 'success'> = {
urgent: 'error',
high: 'warning',
medium: 'info',
low: 'success',
};
const AlertGroupHeader: React.FC<AlertGroupHeaderProps> = ({
group,
isCollapsed,
onToggleCollapse,
}) => {
const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low;
const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info';
return (
<button
onClick={onToggleCollapse}
className={`
w-full flex items-center justify-between p-3.5 rounded-lg border-2 transition-all
${severityConfig}
hover:shadow-md cursor-pointer hover:scale-[1.01]
`}
aria-expanded={!isCollapsed}
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${group.title}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{isCollapsed ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronUp className="w-5 h-5" />
)}
</div>
<div className="text-left flex-1 min-w-0">
<h3 className="font-bold text-sm truncate">
{group.title}
</h3>
{group.type === 'similarity' && group.count > 1 && (
<p className="text-xs opacity-75 mt-0.5">
{group.alerts.length} alertas similares
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
{group.count > 1 && (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-white/60 backdrop-blur-sm rounded-lg border border-current/20 min-h-[1.625rem]">
<span className="text-xs font-bold leading-none">{group.count}</span>
<span className="text-xs opacity-75 leading-none">alertas</span>
</div>
)}
{group.severity && (
<Badge variant={badgeVariant} size="sm" className="font-bold px-2.5 py-1 min-h-[1.625rem]">
{group.severity.toUpperCase()}
</Badge>
)}
</div>
</button>
);
};
export default AlertGroupHeader;

View File

@@ -0,0 +1,118 @@
import React, { useState } from 'react';
import { Clock, X } from 'lucide-react';
import { Button } from '../../ui/Button';
export interface AlertSnoozeMenuProps {
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onCancel: () => void;
}
const PRESET_DURATIONS = [
{ value: '15min' as const, label: '15 minutos', icon: '⏰' },
{ value: '1hr' as const, label: '1 hora', icon: '🕐' },
{ value: '4hr' as const, label: '4 horas', icon: '🕓' },
{ value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' },
];
const AlertSnoozeMenu: React.FC<AlertSnoozeMenuProps> = ({
onSnooze,
onCancel,
}) => {
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState(1);
const handleCustomSnooze = () => {
const milliseconds = customHours * 60 * 60 * 1000;
onSnooze(milliseconds);
};
return (
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[240px]">
{/* Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-[var(--color-primary)]" />
<span className="text-sm font-semibold text-[var(--text-primary)]">
Posponer hasta
</span>
</div>
<button
onClick={onCancel}
className="p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
<X className="w-4 h-4 text-[var(--text-secondary)]" />
</button>
</div>
{!showCustom ? (
<>
{/* Preset Options */}
<div className="space-y-1 mb-2">
{PRESET_DURATIONS.map((preset) => (
<button
key={preset.value}
onClick={() => onSnooze(preset.value)}
className="w-full px-3 py-2 text-left text-sm rounded-lg hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
>
<span className="text-lg">{preset.icon}</span>
<span>{preset.label}</span>
</button>
))}
</div>
{/* Custom Option */}
<button
onClick={() => setShowCustom(true)}
className="w-full px-3 py-2 text-left text-sm rounded-lg border border-dashed border-[var(--border-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-secondary)]"
>
Personalizado...
</button>
</>
) : (
<>
{/* Custom Time Input */}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Número de horas
</label>
<input
type="number"
min="1"
max="168"
value={customHours}
onChange={(e) => setCustomHours(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
autoFocus
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Máximo 168 horas (7 días)
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowCustom(false)}
className="flex-1"
>
Atrás
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCustomSnooze}
className="flex-1"
>
Confirmar
</Button>
</div>
</div>
</>
)}
</div>
);
};
export default AlertSnoozeMenu;

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics';
export interface AlertTrendsProps {
analytics: AlertAnalytics | undefined;
className?: string;
}
const AlertTrends: React.FC<AlertTrendsProps> = ({ analytics, className }) => {
// Debug logging
console.log('[AlertTrends] Received analytics:', analytics);
console.log('[AlertTrends] Has trends?', analytics?.trends);
console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends));
// Safety check: handle undefined or missing analytics data
if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) {
console.log('[AlertTrends] Showing loading state');
return (
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)]">
Cargando analíticas...
</div>
</div>
);
}
console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends');
// Ensure we have valid trend data
const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number');
const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Hoy';
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Ayer';
}
return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' });
};
return (
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
<h3 className="font-semibold text-[var(--text-primary)]">
Tendencias (7 días)
</h3>
</div>
<Badge variant="secondary" size="sm">
{analytics.totalAlerts} total
</Badge>
</div>
{/* Chart */}
<div className="mb-4">
<div className="flex items-end justify-between gap-1 h-32">
{analytics.trends.map((trend, index) => {
const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
return (
<div
key={trend.date}
className="flex-1 flex flex-col items-center gap-1"
>
<div className="w-full flex flex-col justify-end" style={{ height: '100px' }}>
{/* Bar */}
<div
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/60 rounded-t transition-all hover:opacity-80 cursor-pointer relative group"
style={{ height: `${heightPercentage}%`, minHeight: trend.count > 0 ? '4px' : '0' }}
title={`${trend.count} alertas`}
>
{/* Tooltip on hover */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="bg-[var(--text-primary)] text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{trend.count} alertas
<div className="text-[10px] opacity-75">
🔴 {trend.urgentCount} 🟠 {trend.highCount} 🔵 {trend.mediumCount} 🟢 {trend.lowCount}
</div>
</div>
</div>
</div>
</div>
{/* Label */}
<span className="text-[10px] text-[var(--text-secondary)] text-center">
{formatDate(trend.date)}
</span>
</div>
);
})}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-[var(--border-primary)]">
{/* Average Response Time */}
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-500/10 rounded">
<Clock className="w-4 h-4 text-blue-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Respuesta promedio</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'}
</div>
</div>
</div>
{/* Daily Average */}
<div className="flex items-center gap-2">
<div className="p-2 bg-purple-500/10 rounded">
<TrendingUp className="w-4 h-4 text-purple-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Promedio diario</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.predictedDailyAverage} alertas
</div>
</div>
</div>
{/* Resolution Rate */}
<div className="flex items-center gap-2">
<div className="p-2 bg-green-500/10 rounded">
<BarChart3 className="w-4 h-4 text-green-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Tasa de resolución</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.resolutionRate}%
</div>
</div>
</div>
{/* Busiest Day */}
<div className="flex items-center gap-2">
<div className="p-2 bg-orange-500/10 rounded">
<AlertTriangle className="w-4 h-4 text-orange-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Día más activo</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.busiestDay}
</div>
</div>
</div>
</div>
{/* Top Categories */}
{analytics.topCategories.length > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
<div className="text-xs font-semibold text-[var(--text-secondary)] mb-2">
Categorías principales
</div>
<div className="flex flex-wrap gap-2">
{analytics.topCategories.map((cat) => (
<Badge key={cat.category} variant="secondary" size="sm">
{cat.count} ({cat.percentage}%)
</Badge>
))}
</div>
</div>
)}
</div>
);
};
export default AlertTrends;

View File

@@ -0,0 +1,425 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { StatusCard } from '../../ui/StatusCard/StatusCard';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { usePendingApprovalPurchaseOrders, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../../api/hooks/purchase-orders';
import {
ShoppingCart,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
ChevronRight,
Calendar,
Package,
TruckIcon,
Euro,
FileCheck
} from 'lucide-react';
export interface PendingPOApprovalsProps {
className?: string;
maxPOs?: number;
onApprovePO?: (poId: string) => void;
onRejectPO?: (poId: string) => void;
onViewDetails?: (poId: string) => void;
onViewAllPOs?: () => void;
}
const PendingPOApprovals: React.FC<PendingPOApprovalsProps> = ({
className,
maxPOs = 5,
onApprovePO,
onRejectPO,
onViewDetails,
onViewAllPOs
}) => {
const { t } = useTranslation(['dashboard']);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const [approvingPO, setApprovingPO] = useState<string | null>(null);
const [rejectingPO, setRejectingPO] = useState<string | null>(null);
// Fetch pending approval POs
const { data: pendingPOs, isLoading, error } = usePendingApprovalPurchaseOrders(
tenantId,
50,
{
enabled: !!tenantId,
}
);
// Mutations
const approveMutation = useApprovePurchaseOrder({
onSuccess: () => {
setApprovingPO(null);
if (approvingPO && onApprovePO) {
onApprovePO(approvingPO);
}
},
onError: (error) => {
console.error('Failed to approve PO:', error);
setApprovingPO(null);
}
});
const rejectMutation = useRejectPurchaseOrder({
onSuccess: () => {
setRejectingPO(null);
if (rejectingPO && onRejectPO) {
onRejectPO(rejectingPO);
}
},
onError: (error) => {
console.error('Failed to reject PO:', error);
setRejectingPO(null);
}
});
const handleApprovePO = async (poId: string) => {
setApprovingPO(poId);
await approveMutation.mutateAsync({
tenantId,
poId,
notes: 'Approved from dashboard'
});
};
const handleRejectPO = async (poId: string) => {
setRejectingPO(poId);
await rejectMutation.mutateAsync({
tenantId,
poId,
reason: 'Rejected from dashboard - requires review'
});
};
const getPOPriorityConfig = (priority: string) => {
switch (priority.toLowerCase()) {
case 'urgent':
return {
color: 'var(--color-error)',
text: 'Urgente',
icon: AlertTriangle,
isCritical: true,
isHighlight: false
};
case 'high':
return {
color: 'var(--color-warning)',
text: 'Alta',
icon: Clock,
isCritical: false,
isHighlight: true
};
case 'normal':
return {
color: 'var(--color-info)',
text: 'Normal',
icon: Package,
isCritical: false,
isHighlight: false
};
case 'low':
return {
color: 'var(--color-success)',
text: 'Baja',
icon: Clock,
isCritical: false,
isHighlight: false
};
default:
return {
color: 'var(--color-info)',
text: 'Normal',
icon: Package,
isCritical: false,
isHighlight: false
};
}
};
const formatCurrency = (amount: string, currency: string = 'EUR') => {
const value = parseFloat(amount);
if (currency === 'EUR') {
return `${value.toFixed(2)}`;
}
return `${value.toFixed(2)} ${currency}`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `Vencido hace ${Math.abs(diffDays)} días`;
} else if (diffDays === 0) {
return 'Hoy';
} else if (diffDays === 1) {
return 'Mañana';
} else if (diffDays <= 7) {
return `En ${diffDays} días`;
}
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
};
// Process POs and sort by priority and delivery date
const displayPOs = useMemo(() => {
if (!pendingPOs || !Array.isArray(pendingPOs)) return [];
const pos = [...pendingPOs];
// Sort by priority and delivery date
const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 };
pos.sort((a, b) => {
// First by priority
const aPriority = priorityOrder[a.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4;
const bPriority = priorityOrder[b.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4;
if (aPriority !== bPriority) return aPriority - bPriority;
// Then by delivery date (earliest first)
if (a.required_delivery_date && b.required_delivery_date) {
const aDate = new Date(a.required_delivery_date).getTime();
const bDate = new Date(b.required_delivery_date).getTime();
return aDate - bDate;
}
// Finally by created date (oldest first - longest waiting)
const aCreated = new Date(a.created_at).getTime();
const bCreated = new Date(b.created_at).getTime();
return aCreated - bCreated;
});
return pos.slice(0, maxPOs);
}, [pendingPOs, maxPOs]);
const urgentPOs = pendingPOs?.filter(po => po.priority === 'urgent' || po.priority === 'high').length || 0;
const totalPOs = pendingPOs?.length || 0;
// Calculate total amount pending approval
const totalAmount = useMemo(() => {
if (!displayPOs || displayPOs.length === 0) return '0.00';
const sum = displayPOs.reduce((acc, po) => acc + parseFloat(po.total_amount || '0'), 0);
return sum.toFixed(2);
}, [displayPOs]);
if (isLoading) {
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
</p>
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
</p>
</div>
</div>
</CardHeader>
<CardBody>
<div className="p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-[var(--color-error)] text-sm">
{t('dashboard:messages.error_loading', 'Error al cargar los datos')}
</p>
</div>
</CardBody>
</Card>
);
}
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center justify-between w-full flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{urgentPOs > 0 && (
<Badge variant="error" size="sm">
{urgentPOs} urgentes
</Badge>
)}
{totalPOs > 0 && (
<Badge variant="warning" size="sm">
{totalPOs} pendientes
</Badge>
)}
<div className="flex items-center gap-1 text-sm font-semibold text-[var(--text-primary)]">
<Euro className="w-4 h-4" />
<span>{formatCurrency(totalAmount)}</span>
</div>
</div>
</div>
</CardHeader>
<CardBody padding="none">
{displayPOs.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 text-[var(--text-primary)]">
{t('dashboard:po_approvals.empty', 'Sin órdenes pendientes de aprobación')}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Todas las órdenes de compra están aprobadas o en proceso
</p>
</div>
) : (
<div className="space-y-3 p-4">
{displayPOs.map((po) => {
const priorityConfig = getPOPriorityConfig(po.priority);
const deliveryDate = po.required_delivery_date
? formatDate(po.required_delivery_date)
: 'Sin fecha';
const isApproving = approvingPO === po.id;
const isRejecting = rejectingPO === po.id;
return (
<StatusCard
key={po.id}
id={po.id}
statusIndicator={priorityConfig}
title={po.supplier_name || 'Proveedor desconocido'}
subtitle={`PO #${po.po_number}`}
primaryValue={formatCurrency(po.total_amount, po.currency)}
primaryValueLabel="MONTO TOTAL"
secondaryInfo={{
label: 'Entrega requerida',
value: deliveryDate
}}
metadata={[
`📦 Orden: ${po.po_number}`,
`📅 Creada: ${new Date(po.created_at).toLocaleDateString('es-ES')}`,
`🚚 Entrega: ${deliveryDate}`,
...(po.priority === 'urgent' ? [`⚠️ URGENTE - Requiere aprobación inmediata`] : []),
...(po.priority === 'high' ? [`⚡ ALTA PRIORIDAD`] : [])
]}
actions={[
{
label: isApproving ? 'Aprobando...' : 'Aprobar',
icon: CheckCircle,
variant: 'primary' as const,
onClick: () => handleApprovePO(po.id),
priority: 'primary' as const,
disabled: isApproving || isRejecting
},
{
label: isRejecting ? 'Rechazando...' : 'Rechazar',
icon: XCircle,
variant: 'outline' as const,
onClick: () => handleRejectPO(po.id),
priority: 'secondary' as const,
destructive: true,
disabled: isApproving || isRejecting
},
{
label: 'Ver Detalles',
icon: ChevronRight,
variant: 'outline' as const,
onClick: () => onViewDetails?.(po.id),
priority: 'secondary' as const
}
]}
compact={true}
className="border-l-4"
/>
);
})}
</div>
)}
{displayPOs.length > 0 && (
<div
className="p-4 border-t"
style={{
borderColor: 'var(--border-primary)',
backgroundColor: 'var(--bg-secondary)'
}}
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="text-sm">
<span className="text-[var(--text-secondary)]">
{totalPOs} {t('dashboard:po_approvals.pos_pending', 'órdenes pendientes de aprobación')}
</span>
{urgentPOs > 0 && (
<span className="ml-2 text-[var(--color-error)] font-semibold">
{urgentPOs} urgentes
</span>
)}
</div>
{onViewAllPOs && (
<Button
variant="outline"
size="sm"
onClick={onViewAllPOs}
className="flex items-center gap-2"
>
Ver Todas las Órdenes
<ChevronRight className="w-4 h-4" />
</Button>
)}
</div>
</div>
)}
</CardBody>
</Card>
);
};
export default PendingPOApprovals;

View File

@@ -1,297 +0,0 @@
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,
Euro,
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;

View File

@@ -1,665 +0,0 @@
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,
ChefHat,
Eye,
Scale,
FlaskRound,
CircleDot,
ArrowRight,
CheckSquare,
XSquare,
Zap,
Snowflake,
Box
} from 'lucide-react';
export interface QualityCheckRequirement {
id: string;
name: string;
stage: ProcessStage;
isRequired: boolean;
isCritical: boolean;
status: 'pending' | 'completed' | 'failed' | 'skipped';
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
}
export interface ProcessStageInfo {
current: ProcessStage;
history: Array<{
stage: ProcessStage;
timestamp: string;
duration?: number;
}>;
pendingQualityChecks: QualityCheckRequirement[];
completedQualityChecks: QualityCheckRequirement[];
}
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
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;
}>;
processStage?: ProcessStageInfo;
}
export interface ProductionPlansProps {
className?: string;
orders?: ProductionOrder[];
onStartOrder?: (orderId: string) => void;
onPauseOrder?: (orderId: string) => void;
onViewDetails?: (orderId: string) => void;
onViewAllPlans?: () => void;
}
const getProcessStageIcon = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return ChefHat;
case 'proofing': return Timer;
case 'shaping': return Package;
case 'baking': return Flame;
case 'cooling': return Snowflake;
case 'packaging': return Box;
case 'finishing': return CheckCircle;
default: return CircleDot;
}
};
const getProcessStageColor = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'var(--color-info)';
case 'proofing': return 'var(--color-warning)';
case 'shaping': return 'var(--color-primary)';
case 'baking': return 'var(--color-error)';
case 'cooling': return 'var(--color-info)';
case 'packaging': return 'var(--color-success)';
case 'finishing': return 'var(--color-success)';
default: return 'var(--color-gray)';
}
};
const getProcessStageLabel = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'Mezclado';
case 'proofing': return 'Fermentado';
case 'shaping': return 'Formado';
case 'baking': return 'Horneado';
case 'cooling': return 'Enfriado';
case 'packaging': return 'Empaquetado';
case 'finishing': return 'Acabado';
default: return 'Sin etapa';
}
};
const getQualityCheckIcon = (checkType: string) => {
switch (checkType) {
case 'visual': return Eye;
case 'measurement': return Scale;
case 'temperature': return Thermometer;
case 'weight': return Scale;
case 'boolean': return CheckSquare;
default: return FlaskRound;
}
};
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 }
],
processStage: {
current: 'baking',
history: [
{ stage: 'mixing', timestamp: '06:00', duration: 30 },
{ stage: 'proofing', timestamp: '06:30', duration: 90 },
{ stage: 'shaping', timestamp: '08:00', duration: 15 },
{ stage: 'baking', timestamp: '08:15' }
],
pendingQualityChecks: [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'pending',
checkType: 'temperature'
}
],
completedQualityChecks: [
{
id: 'qc2',
name: 'Inspección visual de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
}
]
}
},
{
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 }
],
processStage: {
current: 'shaping',
history: [
{ stage: 'proofing', timestamp: '07:30', duration: 120 }
],
pendingQualityChecks: [
{
id: 'qc3',
name: 'Verificar formado de hojaldre',
stage: 'shaping',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
},
{
id: 'qc4',
name: 'Control de peso individual',
stage: 'shaping',
isRequired: false,
isCritical: false,
status: 'pending',
checkType: 'weight'
}
],
completedQualityChecks: []
}
},
{
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 }
],
processStage: {
current: 'finishing',
history: [
{ stage: 'mixing', timestamp: '05:00', duration: 20 },
{ stage: 'proofing', timestamp: '05:20', duration: 120 },
{ stage: 'shaping', timestamp: '07:20', duration: 30 },
{ stage: 'baking', timestamp: '07:50', duration: 45 },
{ stage: 'cooling', timestamp: '08:35', duration: 30 },
{ stage: 'finishing', timestamp: '09:05' }
],
pendingQualityChecks: [],
completedQualityChecks: [
{
id: 'qc5',
name: 'Inspección visual final',
stage: 'finishing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
},
{
id: 'qc6',
name: 'Control de temperatura de cocción',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'completed',
checkType: 'temperature'
}
]
}
},
{
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 }
],
processStage: {
current: 'mixing',
history: [],
pendingQualityChecks: [
{
id: 'qc7',
name: 'Verificar consistencia de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
}
],
completedQualityChecks: []
}
}
];
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;
// Cross-batch quality overview calculations
const totalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.length || 0), 0);
const criticalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.filter(qc => qc.isCritical).length || 0), 0);
const ordersBlockedByQuality = displayOrders.filter(order =>
order.processStage?.pendingQualityChecks.some(qc => qc.isCritical && qc.isRequired) || false).length;
// Helper function to create enhanced metadata with process stage info
const createEnhancedMetadata = (order: ProductionOrder) => {
const baseMetadata = [
`⏰ Inicio: ${order.startTime}`,
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : [])
];
if (order.processStage) {
const { current, pendingQualityChecks, completedQualityChecks } = order.processStage;
const currentStageIcon = getProcessStageIcon(current);
const currentStageLabel = getProcessStageLabel(current);
// Add current stage info
baseMetadata.push(`🔄 Etapa: ${currentStageLabel}`);
// Add quality check info
if (pendingQualityChecks.length > 0) {
const criticalPending = pendingQualityChecks.filter(qc => qc.isCritical).length;
const requiredPending = pendingQualityChecks.filter(qc => qc.isRequired).length;
if (criticalPending > 0) {
baseMetadata.push(`🚨 ${criticalPending} controles críticos pendientes`);
} else if (requiredPending > 0) {
baseMetadata.push(`${requiredPending} controles requeridos pendientes`);
} else {
baseMetadata.push(`📋 ${pendingQualityChecks.length} controles opcionales pendientes`);
}
}
if (completedQualityChecks.length > 0) {
baseMetadata.push(`${completedQualityChecks.length} controles completados`);
}
}
// Add ingredients info
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
const totalIngredients = order.ingredients.length;
const ingredientsReady = availableIngredients === totalIngredients;
baseMetadata.push(`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`);
// Add notes if any
if (order.notes) {
baseMetadata.push(`📝 ${order.notes}`);
}
return baseMetadata;
};
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">
{ordersBlockedByQuality > 0 && (
<Badge variant="error" size="sm">
🚨 {ordersBlockedByQuality} bloqueadas por calidad
</Badge>
)}
{criticalPendingQualityChecks > 0 && ordersBlockedByQuality === 0 && (
<Badge variant="warning" size="sm">
🔍 {criticalPendingQualityChecks} controles críticos
</Badge>
)}
{totalPendingQualityChecks > 0 && criticalPendingQualityChecks === 0 && (
<Badge variant="info" size="sm">
📋 {totalPendingQualityChecks} controles pendientes
</Badge>
)}
{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 enhancedMetadata = createEnhancedMetadata(order);
// Enhanced secondary info that includes stage information
const getSecondaryInfo = () => {
if (order.processStage) {
const currentStageLabel = getProcessStageLabel(order.processStage.current);
return {
label: 'Etapa actual',
value: `${currentStageLabel}${order.assignedBaker}`
};
}
return {
label: 'Panadero asignado',
value: order.assignedBaker
};
};
// Enhanced status indicator with process stage color for active orders
const getEnhancedStatusConfig = () => {
if (order.processStage && order.status === 'in_progress') {
return {
...statusConfig,
color: getProcessStageColor(order.processStage.current)
};
}
return statusConfig;
};
return (
<StatusCard
key={order.id}
id={order.id}
statusIndicator={getEnhancedStatusConfig()}
title={order.product}
subtitle={`${order.recipe}${order.quantity} ${order.unit}`}
primaryValue={`${order.progress}%`}
primaryValueLabel="PROGRESO"
secondaryInfo={getSecondaryInfo()}
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={enhancedMetadata}
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
}] : []),
// Add quality check action if there are pending quality checks
...(order.processStage?.pendingQualityChecks.length > 0 ? [{
label: `${order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? '🚨 ' : ''}Controles Calidad`,
icon: FlaskRound,
variant: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'outline' as const,
onClick: () => onViewDetails?.(order.id), // This would open the quality check modal
priority: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'secondary' as const
}] : []),
// Add next stage action for orders that can progress
...(order.status === 'in_progress' && order.processStage && order.processStage.pendingQualityChecks.length === 0 ? [{
label: 'Siguiente Etapa',
icon: ArrowRight,
variant: 'primary' as const,
onClick: () => console.log(`Advancing stage for order ${order.id}`),
priority: 'primary' as const
}] : []),
{
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;

View File

@@ -1,210 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { Button } from '../../ui/Button';
import {
FileText,
CheckCircle,
Clock,
Truck,
AlertCircle,
ChevronRight,
Euro,
Calendar,
Package
} from 'lucide-react';
import { useProcurementDashboard } from '../../../api/hooks/orders';
import { useCurrentTenant } from '../../../stores/tenant.store';
const PurchaseOrdersTracking: React.FC = () => {
const navigate = useNavigate();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const { data: dashboard, isLoading } = useProcurementDashboard(tenantId);
const getStatusIcon = (status: string) => {
switch (status) {
case 'draft':
return <Clock className="w-4 h-4" />;
case 'pending_approval':
return <AlertCircle className="w-4 h-4" />;
case 'approved':
return <CheckCircle className="w-4 h-4" />;
case 'in_execution':
return <Truck className="w-4 h-4" />;
case 'completed':
return <CheckCircle className="w-4 h-4" />;
default:
return <FileText className="w-4 h-4" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'draft':
return 'text-[var(--text-tertiary)] bg-[var(--bg-tertiary)]';
case 'pending_approval':
return 'text-yellow-700 bg-yellow-100';
case 'approved':
return 'text-green-700 bg-green-100';
case 'in_execution':
return 'text-blue-700 bg-blue-100';
case 'completed':
return 'text-green-700 bg-green-100';
case 'cancelled':
return 'text-red-700 bg-red-100';
default:
return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)]';
}
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
draft: 'Borrador',
pending_approval: 'Pendiente Aprobación',
approved: 'Aprobado',
in_execution: 'En Ejecución',
completed: 'Completado',
cancelled: 'Cancelado'
};
return labels[status] || status;
};
const handleViewAllPOs = () => {
navigate('/app/operations/procurement');
};
const handleViewPODetails = (planId: string) => {
navigate(`/app/operations/procurement?plan=${planId}`);
};
if (isLoading) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
</div>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
</CardHeader>
<CardBody>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
</CardBody>
</Card>
);
}
const recentPlans = dashboard?.recent_plans || [];
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleViewAllPOs}
className="text-[var(--color-primary)] hover:text-[var(--color-primary)]/80"
>
Ver Todas
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
</CardHeader>
<CardBody>
{recentPlans.length === 0 ? (
<div className="text-center py-8">
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-3" />
<p className="text-[var(--text-secondary)]">No hay órdenes de compra recientes</p>
<Button
variant="outline"
size="sm"
onClick={handleViewAllPOs}
className="mt-4"
>
Crear Plan de Compras
</Button>
</div>
) : (
<div className="space-y-3">
{recentPlans.slice(0, 5).map((plan: any) => (
<div
key={plan.id}
className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
onClick={() => handleViewPODetails(plan.id)}
>
<div className="flex items-start gap-3 flex-1">
<div className={`p-2 rounded-lg ${getStatusColor(plan.status)}`}>
{getStatusIcon(plan.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-[var(--text-primary)]">
{plan.plan_number}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(plan.status)}`}>
{getStatusLabel(plan.status)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
<span>{new Date(plan.plan_date).toLocaleDateString('es-ES')}</span>
</div>
<div className="flex items-center gap-1">
<Package className="w-3.5 h-3.5" />
<span>{plan.total_requirements} items</span>
</div>
<div className="flex items-center gap-1">
<Euro className="w-3.5 h-3.5" />
<span>{plan.total_estimated_cost?.toFixed(2) || '0.00'}</span>
</div>
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] flex-shrink-0" />
</div>
))}
</div>
)}
{/* Summary Stats */}
{dashboard?.stats && (
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-[var(--border-primary)]">
<div className="text-center">
<div className="text-2xl font-bold text-[var(--text-primary)]">
{dashboard.stats.total_plans || 0}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">Total Planes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-[var(--color-success)]">
{dashboard.stats.approved_plans || 0}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">Aprobados</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-[var(--color-warning)]">
{dashboard.stats.pending_plans || 0}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">Pendientes</div>
</div>
</div>
)}
</CardBody>
</Card>
);
};
export default PurchaseOrdersTracking;

View File

@@ -1,165 +1,288 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useNotifications } from '../../../hooks/useNotifications';
import { useAlertFilters } from '../../../hooks/useAlertFilters';
import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping';
import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics';
import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation';
import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers';
import {
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
Clock,
X,
Bell,
Wifi,
WifiOff,
Bell,
ChevronDown,
ChevronUp,
Check,
Trash2
CheckCircle,
BarChart3,
AlertTriangle,
AlertCircle,
Clock,
} from 'lucide-react';
export interface Alert {
id: string;
item_type: 'alert' | 'recommendation';
type: string;
severity: 'urgent' | 'high' | 'medium' | 'low';
title: string;
message: string;
timestamp: string;
actions?: string[];
metadata?: any;
status?: 'active' | 'resolved' | 'acknowledged';
}
import AlertFilters from './AlertFilters';
import AlertGroupHeader from './AlertGroupHeader';
import AlertCard from './AlertCard';
import AlertTrends from './AlertTrends';
import AlertBulkActions from './AlertBulkActions';
export interface RealTimeAlertsProps {
className?: string;
maxAlerts?: number;
showAnalytics?: boolean;
showGrouping?: boolean;
}
/**
* RealTimeAlerts - Dashboard component for displaying today's active alerts
*
* IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards)
* to prevent flooding the dashboard with historical data.
*
* For historical alert data, use the Analytics panel or API endpoints:
* - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.)
* - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics
*
* Alert scopes across the application:
* - Dashboard (this component): TODAY'S alerts only
* - Notification Bell: Last 24 hours
* - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.)
* - localStorage: Auto-cleanup of alerts >24h old on load
* - Redis cache (initial_items): TODAY'S alerts only
*/
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
className,
maxAlerts = 10
maxAlerts = 50,
showAnalytics = true,
showGrouping = true,
}) => {
const { t } = useTranslation(['dashboard']);
const [expandedAlert, setExpandedAlert] = useState<string | null>(null);
const [expandedAlerts, setExpandedAlerts] = useState<Set<string>>(new Set());
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
const [showBulkActions, setShowBulkActions] = useState(false);
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
const { notifications, isConnected, markAsRead, removeNotification } = useNotifications();
const {
notifications,
isConnected,
markAsRead,
removeNotification,
snoozeAlert,
unsnoozeAlert,
isAlertSnoozed,
snoozedAlerts,
markMultipleAsRead,
removeMultiple,
snoozeMultiple,
} = useNotifications();
// Convert notifications to alerts format and limit them
const alerts = notifications.slice(0, maxAlerts).map(notification => ({
id: notification.id,
item_type: notification.item_type,
type: notification.item_type, // Use item_type as type
severity: notification.severity,
title: notification.title,
message: notification.message,
timestamp: notification.timestamp,
status: notification.read ? 'acknowledged' as const : 'active' as const,
}));
const {
filters,
toggleSeverity,
toggleCategory,
setTimeRange,
setSearch,
toggleShowSnoozed,
clearFilters,
hasActiveFilters,
activeFilterCount,
} = useAlertFilters();
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
return Info;
}
};
// Dashboard shows only TODAY's alerts
// Analytics panel shows historical data (configured separately)
const filteredNotifications = useMemo(() => {
// Filter to today's alerts only for dashboard display
// This prevents showing yesterday's or older alerts on the main dashboard
const todayAlerts = notifications.filter(alert => {
const timeGroup = getTimeGroup(alert.timestamp);
return timeGroup === 'today';
});
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'urgent':
return 'var(--color-error)';
case 'high':
return 'var(--color-warning)';
case 'medium':
return 'var(--color-info)';
case 'low':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
};
return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts);
}, [notifications, filters, snoozedAlerts, maxAlerts]);
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'urgent':
return 'error';
case 'high':
return 'warning';
case 'medium':
return 'info';
case 'low':
return 'success';
default:
return 'info';
}
};
const {
groupedAlerts,
groupingMode,
setGroupingMode,
toggleGroupCollapse,
isGroupCollapsed,
} = useAlertGrouping(filteredNotifications, 'time');
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));
const analytics = useAlertAnalytics(notifications);
const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking();
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
? t('dashboard:alerts.time.yesterday', 'Ayer')
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
const stats = useMemo(() => {
return getAlertStatistics(filteredNotifications, snoozedAlerts);
}, [filteredNotifications, snoozedAlerts]);
const toggleExpanded = (alertId: string) => {
setExpandedAlert(prev => prev === alertId ? null : alertId);
};
const flatAlerts = useMemo(() => {
return groupedAlerts.flatMap(group =>
isGroupCollapsed(group.id) ? [] : group.alerts
);
}, [groupedAlerts, isGroupCollapsed]);
const handleMarkAsRead = (alertId: string) => {
const { focusedIndex } = useKeyboardNavigation(
flatAlerts.length,
{
onMoveUp: () => {},
onMoveDown: () => {},
onSelect: () => {
if (flatAlerts[focusedIndex]) {
toggleAlertSelection(flatAlerts[focusedIndex].id);
}
},
onExpand: () => {
if (flatAlerts[focusedIndex]) {
toggleAlertExpansion(flatAlerts[focusedIndex].id);
}
},
onMarkAsRead: () => {
if (flatAlerts[focusedIndex]) {
handleMarkAsRead(flatAlerts[focusedIndex].id);
}
},
onDismiss: () => {
if (flatAlerts[focusedIndex]) {
handleRemoveAlert(flatAlerts[focusedIndex].id);
}
},
onSnooze: () => {
if (flatAlerts[focusedIndex]) {
handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr');
}
},
onEscape: () => {
setExpandedAlerts(new Set());
setSelectedAlerts(new Set());
},
onSelectAll: () => {
handleSelectAll();
},
onSearch: () => {},
},
true
);
const toggleAlertExpansion = useCallback((alertId: string) => {
setExpandedAlerts(prev => {
const next = new Set(prev);
if (next.has(alertId)) {
next.delete(alertId);
} else {
next.add(alertId);
}
return next;
});
}, []);
const toggleAlertSelection = useCallback((alertId: string) => {
setSelectedAlerts(prev => {
const next = new Set(prev);
if (next.has(alertId)) {
next.delete(alertId);
} else {
next.add(alertId);
}
return next;
});
}, []);
const handleMarkAsRead = useCallback((alertId: string) => {
markAsRead(alertId);
};
trackAcknowledgment(alertId).catch(err =>
console.error('Failed to track acknowledgment:', err)
);
}, [markAsRead, trackAcknowledgment]);
const handleRemoveAlert = (alertId: string) => {
const handleRemoveAlert = useCallback((alertId: string) => {
removeNotification(alertId);
if (expandedAlert === alertId) {
setExpandedAlert(null);
}
};
trackResolution(alertId).catch(err =>
console.error('Failed to track resolution:', err)
);
setExpandedAlerts(prev => {
const next = new Set(prev);
next.delete(alertId);
return next;
});
setSelectedAlerts(prev => {
const next = new Set(prev);
next.delete(alertId);
return next;
});
}, [removeNotification, trackResolution]);
const activeAlerts = alerts.filter(alert => alert.status === 'active');
const urgentCount = activeAlerts.filter(alert => alert.severity === 'urgent').length;
const highCount = activeAlerts.filter(alert => alert.severity === 'high').length;
const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
snoozeAlert(alertId, duration);
}, [snoozeAlert]);
const handleUnsnoozeAlert = useCallback((alertId: string) => {
unsnoozeAlert(alertId);
}, [unsnoozeAlert]);
const handleSelectAll = useCallback(() => {
setSelectedAlerts(new Set(flatAlerts.map(a => a.id)));
setShowBulkActions(true);
}, [flatAlerts]);
const handleDeselectAll = useCallback(() => {
setSelectedAlerts(new Set());
setShowBulkActions(false);
}, []);
const handleBulkMarkAsRead = useCallback(() => {
const ids = Array.from(selectedAlerts);
markMultipleAsRead(ids);
ids.forEach(id =>
trackAcknowledgment(id).catch(err =>
console.error('Failed to track acknowledgment:', err)
)
);
handleDeselectAll();
}, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]);
const handleBulkRemove = useCallback(() => {
const ids = Array.from(selectedAlerts);
removeMultiple(ids);
ids.forEach(id =>
trackResolution(id).catch(err =>
console.error('Failed to track resolution:', err)
)
);
handleDeselectAll();
}, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]);
const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
const ids = Array.from(selectedAlerts);
snoozeMultiple(ids, duration);
handleDeselectAll();
}, [selectedAlerts, snoozeMultiple, handleDeselectAll]);
const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id));
const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length;
const highCount = activeAlerts.filter(a => a.severity === 'high').length;
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="md" divider>
<div className="flex items-center justify-between w-full">
<CardHeader padding="lg" divider>
<div className="flex items-start sm:items-center justify-between w-full gap-4 flex-col sm:flex-row">
<div className="flex items-center gap-3">
<div
className="p-2 rounded-lg"
style={{ backgroundColor: 'var(--color-primary)20' }}
className="p-2.5 rounded-xl shadow-sm flex-shrink-0"
style={{ backgroundColor: 'var(--color-primary)15' }}
>
<Bell className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
</div>
<div>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
<h3 className="text-lg font-bold mb-0.5" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.title', 'Alertas')}
</h3>
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="w-3 h-3" style={{ color: 'var(--color-success)' }} />
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
) : (
<WifiOff className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
)}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="text-xs font-medium whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
{isConnected
? t('dashboard:alerts.live', 'En vivo')
: t('dashboard:alerts.offline', 'Desconectado')
@@ -169,204 +292,181 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
</div>
</div>
<div className="flex items-center gap-1">
{urgentCount > 0 && (
<Badge variant="error" size="sm">
{urgentCount}
</Badge>
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
{/* Alert count badges */}
<div className="flex items-center gap-2">
{urgentCount > 0 && (
<Badge
variant="error"
size="sm"
icon={<AlertTriangle className="w-4 h-4" />}
>
{urgentCount} Alto
</Badge>
)}
{highCount > 0 && (
<Badge
variant="warning"
size="sm"
icon={<AlertCircle className="w-4 h-4" />}
>
{highCount} Medio
</Badge>
)}
</div>
{/* Controls */}
{showAnalytics && (
<Button
variant={showAnalyticsPanel ? 'primary' : 'ghost'}
size="sm"
onClick={() => setShowAnalyticsPanel(!showAnalyticsPanel)}
className="h-9"
title="Toggle analytics"
aria-label="Toggle analytics panel"
>
<BarChart3 className="w-4 h-4" />
</Button>
)}
{highCount > 0 && (
<Badge variant="warning" size="sm">
{highCount}
</Badge>
{showGrouping && (
<select
value={groupingMode}
onChange={(e) => setGroupingMode(e.target.value as GroupingMode)}
className="px-3 py-2 text-sm font-medium border-2 border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all cursor-pointer hover:border-[var(--color-primary)]"
aria-label="Group alerts by"
>
<option value="time"> Por tiempo</option>
<option value="category">📁 Por categoría</option>
<option value="similarity">🔗 Similares</option>
<option value="none">📋 Sin agrupar</option>
</select>
)}
</div>
</div>
</CardHeader>
<CardBody padding="none">
{activeAlerts.length === 0 ? (
<div className="p-6 text-center">
<CheckCircle className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:alerts.no_alerts', 'No hay alertas activas')}
</p>
</div>
) : (
<div className="space-y-2 p-2">
{activeAlerts.map((alert) => {
const isExpanded = expandedAlert === alert.id;
const SeverityIcon = getSeverityIcon(alert.severity);
return (
<div
key={alert.id}
className={`
rounded-lg border transition-all duration-200
${isExpanded ? 'ring-2 ring-opacity-20' : 'hover:shadow-sm'}
`}
style={{
borderColor: getSeverityColor(alert.severity) + '40',
backgroundColor: 'var(--bg-primary)',
...(isExpanded && {
ringColor: getSeverityColor(alert.severity),
backgroundColor: 'var(--bg-secondary)'
})
}}
>
{/* Compact Card Header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors rounded-lg"
onClick={() => toggleExpanded(alert.id)}
>
{/* Severity Icon */}
<div
className="flex-shrink-0 p-2 rounded-full"
style={{ backgroundColor: getSeverityColor(alert.severity) + '15' }}
>
<SeverityIcon
className="w-4 h-4"
style={{ color: getSeverityColor(alert.severity) }}
/>
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0">
{/* Title and Timestamp Row */}
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="text-sm font-semibold leading-tight flex-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
</h4>
<span className="text-xs font-medium flex-shrink-0" style={{ color: 'var(--text-secondary)' }}>
{formatTimestamp(alert.timestamp)}
</span>
</div>
{/* Badges Row */}
<div className="flex items-center gap-2 mb-2">
<Badge variant={getSeverityBadge(alert.severity)} size="sm">
{alert.severity.toUpperCase()}
</Badge>
<Badge variant="secondary" size="sm">
{alert.item_type === 'alert'
? `🚨 ${t('dashboard:alerts.types.alert', 'Alerta')}`
: `💡 ${t('dashboard:alerts.types.recommendation', 'Recomendación')}`
}
</Badge>
</div>
{/* Preview message when collapsed */}
{!isExpanded && (
<p className="text-xs leading-relaxed truncate" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
</p>
)}
</div>
{/* Expand/Collapse Button */}
<div className="flex-shrink-0 p-1 rounded-full hover:bg-black/5 transition-colors">
{isExpanded ? (
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
)}
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 pb-3 border-t mt-3 pt-3" style={{ borderColor: getSeverityColor(alert.severity) + '20' }}>
{/* Full Message */}
<div className="mb-4">
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{alert.message}
</p>
</div>
{/* Actions Section */}
{alert.actions && alert.actions.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.recommended_actions', 'Acciones Recomendadas')}
</p>
<div className="space-y-1">
{alert.actions.map((action, index) => (
<div key={index} className="flex items-start gap-2">
<span className="text-xs mt-0.5" style={{ color: getSeverityColor(alert.severity) }}>
</span>
<span className="text-xs leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{action}
</span>
</div>
))}
</div>
</div>
)}
{/* Metadata */}
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
<div className="mb-4 p-2 rounded-md" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<p className="text-xs font-semibold mb-1 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
</p>
<div className="text-xs space-y-1" style={{ color: 'var(--text-secondary)' }}>
{Object.entries(alert.metadata).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(alert.id);
}}
className="h-8 px-3 text-xs font-medium"
>
<Check className="w-3 h-3 mr-1" />
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveAlert(alert.id);
}}
className="h-8 px-3 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-3 h-3 mr-1" />
{t('dashboard:alerts.remove', 'Eliminar')}
</Button>
</div>
</div>
)}
</div>
);
})}
{showAnalyticsPanel && (
<div className="p-4 border-b border-[var(--border-primary)]">
<AlertTrends analytics={analytics} />
</div>
)}
{activeAlerts.length > 0 && (
<div className="px-4 py-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
<AlertFilters
selectedSeverities={filters.severities}
selectedCategories={filters.categories}
selectedTimeRange={filters.timeRange}
searchQuery={filters.search}
showSnoozed={filters.showSnoozed}
onToggleSeverity={toggleSeverity}
onToggleCategory={toggleCategory}
onSetTimeRange={setTimeRange}
onSearchChange={setSearch}
onToggleShowSnoozed={toggleShowSnoozed}
onClearFilters={clearFilters}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
/>
</div>
{selectedAlerts.size > 0 && (
<div className="p-4 border-b border-[var(--border-primary)]">
<AlertBulkActions
selectedCount={selectedAlerts.size}
totalCount={flatAlerts.length}
onMarkAsRead={handleBulkMarkAsRead}
onRemove={handleBulkRemove}
onSnooze={handleBulkSnooze}
onDeselectAll={handleDeselectAll}
onSelectAll={handleSelectAll}
/>
</div>
)}
{filteredNotifications.length === 0 ? (
<div className="p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-success)]/10 mb-4">
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
</div>
<h4 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{hasActiveFilters ? 'Sin resultados' : 'Todo despejado'}
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{hasActiveFilters
? 'No hay alertas que coincidan con los filtros seleccionados'
: t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento')
}
</p>
</div>
) : (
<div className="space-y-3 p-4">
{groupedAlerts.map((group) => (
<div key={group.id}>
{(group.count > 1 || groupingMode !== 'none') && (
<div className="mb-3">
<AlertGroupHeader
group={group}
isCollapsed={isGroupCollapsed(group.id)}
onToggleCollapse={() => toggleGroupCollapse(group.id)}
/>
</div>
)}
{!isGroupCollapsed(group.id) && (
<div className="space-y-3 ml-0">
{group.alerts.map((alert) => (
<AlertCard
key={alert.id}
alert={alert}
isExpanded={expandedAlerts.has(alert.id)}
isSelected={selectedAlerts.has(alert.id)}
isSnoozed={isAlertSnoozed(alert.id)}
snoozedUntil={snoozedAlerts.get(alert.id)?.until}
onToggleExpand={() => toggleAlertExpansion(alert.id)}
onToggleSelect={() => toggleAlertSelection(alert.id)}
onMarkAsRead={() => handleMarkAsRead(alert.id)}
onRemove={() => handleRemoveAlert(alert.id)}
onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)}
onUnsnooze={() => handleUnsnoozeAlert(alert.id)}
showCheckbox={showBulkActions || selectedAlerts.size > 0}
/>
))}
</div>
)}
</div>
))}
</div>
)}
{filteredNotifications.length > 0 && (
<div
className="p-3 border-t text-center"
className="px-4 py-3 border-t"
style={{
borderColor: 'var(--border-primary)',
backgroundColor: 'var(--bg-secondary)'
backgroundColor: 'var(--bg-secondary)/50',
}}
>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:alerts.active_count', '{{count}} alertas activas', { count: activeAlerts.length })}
</p>
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">
Mostrando <span className="font-bold text-[var(--text-primary)]">{filteredNotifications.length}</span> de <span className="font-bold text-[var(--text-primary)]">{notifications.length}</span> alertas
</span>
<div className="flex items-center gap-4">
{stats.unread > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
</span>
)}
{stats.snoozed > 0 && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
</span>
)}
</div>
</div>
</div>
)}
</CardBody>
@@ -374,4 +474,4 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
);
};
export default RealTimeAlerts;
export default RealTimeAlerts;

View File

@@ -0,0 +1,413 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { StatusCard } from '../../ui/StatusCard/StatusCard';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useActiveBatches } from '../../../api/hooks/production';
import {
Factory,
Clock,
Play,
Pause,
CheckCircle,
AlertTriangle,
ChevronRight,
Timer,
ChefHat,
Flame,
Calendar
} from 'lucide-react';
export interface TodayProductionProps {
className?: string;
maxBatches?: number;
onStartBatch?: (batchId: string) => void;
onPauseBatch?: (batchId: string) => void;
onViewDetails?: (batchId: string) => void;
onViewAllPlans?: () => void;
}
const TodayProduction: React.FC<TodayProductionProps> = ({
className,
maxBatches = 5,
onStartBatch,
onPauseBatch,
onViewDetails,
onViewAllPlans
}) => {
const { t } = useTranslation(['dashboard']);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Get today's date
const todayDate = useMemo(() => {
return new Date().toISOString().split('T')[0];
}, []);
// Fetch active production batches
const { data: productionData, isLoading, error } = useActiveBatches(
tenantId,
{
enabled: !!tenantId,
}
);
const getBatchStatusConfig = (batch: any) => {
const baseConfig = {
isCritical: batch.status === 'FAILED' || batch.priority === 'URGENT',
isHighlight: batch.status === 'IN_PROGRESS' || batch.priority === 'HIGH',
};
switch (batch.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: Flame
};
case 'COMPLETED':
return {
...baseConfig,
color: 'var(--color-success)',
text: 'Completado',
icon: CheckCircle
};
case 'ON_HOLD':
return {
...baseConfig,
color: 'var(--color-warning)',
text: 'Pausado',
icon: Pause
};
case 'FAILED':
return {
...baseConfig,
color: 'var(--color-error)',
text: 'Fallido',
icon: AlertTriangle
};
case 'QUALITY_CHECK':
return {
...baseConfig,
color: 'var(--color-info)',
text: 'Control de Calidad',
icon: CheckCircle
};
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`;
};
// Process batches and sort by priority
const displayBatches = useMemo(() => {
if (!productionData?.batches || !Array.isArray(productionData.batches)) return [];
const batches = [...productionData.batches];
// Filter for today's batches only
const todayBatches = batches.filter(batch => {
const batchDate = new Date(batch.planned_start_time || batch.created_at);
return batchDate.toISOString().split('T')[0] === todayDate;
});
// Sort by priority and start time
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
todayBatches.sort((a, b) => {
// First sort by status (pending/in_progress first)
const statusOrder = { PENDING: 0, IN_PROGRESS: 1, QUALITY_CHECK: 2, ON_HOLD: 3, COMPLETED: 4, FAILED: 5, CANCELLED: 6 };
const aStatus = statusOrder[a.status as keyof typeof statusOrder] ?? 7;
const bStatus = statusOrder[b.status as keyof typeof statusOrder] ?? 7;
if (aStatus !== bStatus) return aStatus - bStatus;
// Then by priority
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 4;
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 4;
if (aPriority !== bPriority) return aPriority - bPriority;
// Finally by start time
const aTime = new Date(a.planned_start_time || a.created_at).getTime();
const bTime = new Date(b.planned_start_time || b.created_at).getTime();
return aTime - bTime;
});
return todayBatches.slice(0, maxBatches);
}, [productionData, todayDate, maxBatches]);
const inProgressBatches = productionData?.batches?.filter(
b => b.status === 'IN_PROGRESS'
).length || 0;
const completedBatches = productionData?.batches?.filter(
b => b.status === 'COMPLETED'
).length || 0;
const delayedBatches = productionData?.batches?.filter(
b => b.status === 'FAILED'
).length || 0;
const pendingBatches = productionData?.batches?.filter(
b => b.status === 'PENDING'
).length || 0;
if (isLoading) {
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.production_today', 'Producción de Hoy')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
</p>
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.production_today', 'Producción de Hoy')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
</p>
</div>
</div>
</CardHeader>
<CardBody>
<div className="p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-[var(--color-error)] text-sm">
{t('dashboard:messages.error_loading', 'Error al cargar los datos')}
</p>
</div>
</CardBody>
</Card>
);
}
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center justify-between w-full flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('dashboard:sections.production_today', 'Producción de Hoy')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{delayedBatches > 0 && (
<Badge variant="error" size="sm">
{delayedBatches} retrasados
</Badge>
)}
{inProgressBatches > 0 && (
<Badge variant="info" size="sm">
{inProgressBatches} activos
</Badge>
)}
{completedBatches > 0 && (
<Badge variant="success" size="sm">
{completedBatches} completados
</Badge>
)}
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
<Calendar className="w-4 h-4" />
<span>{new Date(todayDate).toLocaleDateString('es-ES')}</span>
</div>
</div>
</div>
</CardHeader>
<CardBody padding="none">
{displayBatches.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 text-[var(--text-primary)]">
{t('dashboard:production.empty', 'Sin producción programada para hoy')}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
No hay lotes programados para iniciar hoy
</p>
</div>
) : (
<div className="space-y-3 p-4">
{displayBatches.map((batch) => {
const statusConfig = getBatchStatusConfig(batch);
// Calculate progress based on status and time
let progress = 0;
if (batch.status === 'COMPLETED') {
progress = 100;
} else if (batch.status === 'IN_PROGRESS' && batch.actual_start_time && batch.planned_duration_minutes) {
const elapsed = Date.now() - new Date(batch.actual_start_time).getTime();
const elapsedMinutes = elapsed / (1000 * 60);
progress = Math.min(Math.round((elapsedMinutes / batch.planned_duration_minutes) * 100), 99);
} else if (batch.status === 'QUALITY_CHECK') {
progress = 95;
}
const startTime = batch.planned_start_time
? new Date(batch.planned_start_time).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
})
: 'No programado';
const assignedStaff = batch.staff_assigned && batch.staff_assigned.length > 0
? batch.staff_assigned[0]
: 'Sin asignar';
return (
<StatusCard
key={batch.id}
id={batch.id}
statusIndicator={statusConfig}
title={batch.product_name}
subtitle={`Lote ${batch.batch_number}${batch.planned_quantity} unidades`}
primaryValue={`${progress}%`}
primaryValueLabel="PROGRESO"
secondaryInfo={{
label: 'Panadero asignado',
value: assignedStaff
}}
progress={batch.status !== 'PENDING' ? {
label: `Progreso de producción`,
percentage: progress,
color: progress === 100 ? 'var(--color-success)' :
progress > 70 ? 'var(--color-info)' :
progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
} : undefined}
metadata={[
`⏰ Inicio: ${startTime}`,
...(batch.planned_duration_minutes ? [`⏱️ Duración: ${formatDuration(batch.planned_duration_minutes)}`] : []),
...(batch.station_id ? [`🏭 Estación: ${batch.station_id}`] : []),
...(batch.priority === 'URGENT' ? [`⚠️ URGENTE`] : []),
...(batch.production_notes ? [`📋 ${batch.production_notes}`] : [])
]}
actions={[
...(batch.status === 'PENDING' ? [{
label: 'Iniciar',
icon: Play,
variant: 'primary' as const,
onClick: () => onStartBatch?.(batch.id),
priority: 'primary' as const
}] : []),
...(batch.status === 'IN_PROGRESS' ? [{
label: 'Pausar',
icon: Pause,
variant: 'outline' as const,
onClick: () => onPauseBatch?.(batch.id),
priority: 'primary' as const,
destructive: true
}] : []),
{
label: 'Ver Detalles',
icon: ChevronRight,
variant: 'outline' as const,
onClick: () => onViewDetails?.(batch.id),
priority: 'secondary' as const
}
]}
compact={true}
className="border-l-4"
/>
);
})}
</div>
)}
{displayBatches.length > 0 && (
<div
className="p-4 border-t"
style={{
borderColor: 'var(--border-primary)',
backgroundColor: 'var(--bg-secondary)'
}}
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="text-sm">
<span className="text-[var(--text-secondary)]">
{pendingBatches} {t('dashboard:production.batches_pending', 'lotes pendientes')} de {productionData?.batches?.length || 0} total
</span>
</div>
{onViewAllPlans && (
<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 TodayProduction;

View File

@@ -2,9 +2,9 @@
// Existing dashboard components
export { default as RealTimeAlerts } from './RealTimeAlerts';
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
export { default as ProductionPlansToday } from './ProductionPlansToday';
export { default as PurchaseOrdersTracking } from './PurchaseOrdersTracking';
export { default as PendingPOApprovals } from './PendingPOApprovals';
export { default as TodayProduction } from './TodayProduction';
export { default as AlertTrends } from './AlertTrends';
// Production Management Dashboard Widgets
export { default as ProductionCostMonitor } from './ProductionCostMonitor';

View File

@@ -0,0 +1,325 @@
import React, { useState, useMemo } from 'react';
import { Zap, Key, Settings as SettingsIcon, RefreshCw } from 'lucide-react';
import { AddModal, AddModalSection, AddModalField } from '../../ui/AddModal/AddModal';
import { posService } from '../../../api/services/pos';
import { POSProviderConfig, POSSystem, POSEnvironment } from '../../../api/types/pos';
import { useToast } from '../../../hooks/ui/useToast';
import { statusColors } from '../../../styles/colors';
interface CreatePOSConfigModalProps {
isOpen: boolean;
onClose: () => void;
tenantId: string;
onSuccess?: () => void;
existingConfig?: any; // For edit mode
mode?: 'create' | 'edit';
}
/**
* CreatePOSConfigModal - Modal for creating/editing POS configurations
* Uses the standard AddModal component for consistency across the application
*/
export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
isOpen,
onClose,
tenantId,
onSuccess,
existingConfig,
mode = 'create'
}) => {
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<POSSystem | ''>('');
const { addToast } = useToast();
// Supported POS providers configuration
const supportedProviders: POSProviderConfig[] = [
{
id: 'toast',
name: 'Toast POS',
logo: '🍞',
description: 'Sistema POS líder para restaurantes y panaderías. Muy popular en España.',
features: ['Gestión de pedidos', 'Sincronización de inventario', 'Pagos integrados', 'Reportes en tiempo real'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Obten tu API key desde Toast Dashboard > Settings > Integrations' },
{ field: 'restaurant_guid', label: 'Restaurant GUID', type: 'text', required: true, help_text: 'ID único del restaurante en Toast' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación específica' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'square',
name: 'Square POS',
logo: '⬜',
description: 'Solución POS completa con tarifas transparentes. Ampliamente utilizada por pequeñas empresas.',
features: ['Procesamiento de pagos', 'Gestión de inventario', 'Análisis de ventas', 'Integración con e-commerce'],
required_fields: [
{ field: 'application_id', label: 'Application ID', type: 'text', required: true, help_text: 'ID de aplicación de Square Developer Dashboard' },
{ field: 'access_token', label: 'Access Token', type: 'password', required: true, help_text: 'Token de acceso para la API de Square' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación de Square' },
{ field: 'webhook_signature_key', label: 'Webhook Signature Key', type: 'password', required: false, help_text: 'Clave para verificar webhooks (opcional)' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'lightspeed',
name: 'Lightspeed POS',
logo: '⚡',
description: 'Sistema POS empresarial con API abierta e integración con múltiples herramientas.',
features: ['API REST completa', 'Gestión multi-ubicación', 'Reportes avanzados', 'Integración con contabilidad'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Clave API de Lightspeed Retail' },
{ field: 'api_secret', label: 'API Secret', type: 'password', required: true, help_text: 'Secreto API de Lightspeed Retail' },
{ field: 'account_id', label: 'Account ID', type: 'text', required: true, help_text: 'ID de cuenta de Lightspeed' },
{ field: 'shop_id', label: 'Shop ID', type: 'text', required: true, help_text: 'ID de la tienda específica' },
{ field: 'server_region', label: 'Región del Servidor', type: 'select', required: true, options: [
{ value: 'eu', label: 'Europa' },
{ value: 'us', label: 'Estados Unidos' },
{ value: 'ca', label: 'Canadá' }
]},
],
},
];
// Initialize from existing config in edit mode
const initialData = useMemo(() => {
if (mode === 'edit' && existingConfig) {
// Extract credentials from existing config
const credentials: Record<string, any> = {};
const provider = supportedProviders.find(p => p.id === existingConfig.pos_system);
if (provider && existingConfig.provider_settings) {
provider.required_fields.forEach(field => {
if (existingConfig.provider_settings[field.field]) {
credentials[`credential_${field.field}`] = existingConfig.provider_settings[field.field];
}
});
}
return {
provider: existingConfig.pos_system || '',
config_name: existingConfig.provider_name || '',
auto_sync_enabled: existingConfig.sync_enabled ?? true,
sync_interval_minutes: existingConfig.sync_interval_minutes || '5',
sync_sales: existingConfig.auto_sync_transactions ?? true,
sync_inventory: existingConfig.auto_sync_products ?? true,
...credentials
};
}
return {
provider: '',
config_name: '',
auto_sync_enabled: true,
sync_interval_minutes: '5',
sync_sales: true,
sync_inventory: true,
};
}, [mode, existingConfig, supportedProviders]);
// Build dynamic sections based on selected provider
const sections: AddModalSection[] = useMemo(() => {
const baseSections: AddModalSection[] = [
{
title: 'Información del Proveedor',
icon: Zap,
columns: 1,
fields: [
{
label: 'Sistema POS',
name: 'provider',
type: 'select',
required: true,
placeholder: 'Selecciona un sistema POS',
options: supportedProviders.map(provider => ({
value: provider.id,
label: `${provider.logo} ${provider.name}`
})),
span: 2
},
{
label: 'Nombre de la Configuración',
name: 'config_name',
type: 'text',
required: true,
placeholder: 'Ej: Mi Square POS 2025',
helpText: 'Un nombre descriptivo para identificar esta configuración',
span: 2
}
]
}
];
// Add credentials section if provider is selected
const provider = supportedProviders.find(p => p.id === selectedProvider);
if (provider) {
const credentialFields: AddModalField[] = provider.required_fields.map(field => ({
label: field.label,
name: `credential_${field.field}`,
type: field.type === 'select' ? 'select' : (field.type === 'password' ? 'text' : field.type),
required: field.required,
placeholder: field.placeholder || `Ingresa ${field.label}`,
helpText: field.help_text,
options: field.options,
span: field.type === 'select' ? 2 : 1
}));
baseSections.push({
title: 'Credenciales de API',
icon: Key,
columns: 2,
fields: credentialFields
});
}
// Add sync settings section
baseSections.push({
title: 'Configuración de Sincronización',
icon: RefreshCw,
columns: 2,
fields: [
{
label: 'Sincronización Automática',
name: 'auto_sync_enabled',
type: 'select',
required: true,
options: [
{ value: 'true', label: 'Activada' },
{ value: 'false', label: 'Desactivada' }
],
defaultValue: 'true'
},
{
label: 'Intervalo de Sincronización',
name: 'sync_interval_minutes',
type: 'select',
required: true,
options: [
{ value: '5', label: '5 minutos' },
{ value: '15', label: '15 minutos' },
{ value: '30', label: '30 minutos' },
{ value: '60', label: '1 hora' }
],
defaultValue: '5'
},
{
label: 'Sincronizar Ventas',
name: 'sync_sales',
type: 'select',
required: true,
options: [
{ value: 'true', label: 'Sí' },
{ value: 'false', label: 'No' }
],
defaultValue: 'true'
},
{
label: 'Sincronizar Inventario',
name: 'sync_inventory',
type: 'select',
required: true,
options: [
{ value: 'true', label: 'Sí' },
{ value: 'false', label: 'No' }
],
defaultValue: 'true'
}
]
});
return baseSections;
}, [selectedProvider, supportedProviders]);
const handleSave = async (formData: Record<string, any>) => {
try {
setLoading(true);
// Find selected provider
const provider = supportedProviders.find(p => p.id === formData.provider);
if (!provider) {
addToast('Por favor selecciona un sistema POS', { type: 'error' });
return;
}
// Extract credentials
const credentials: Record<string, any> = {};
provider.required_fields.forEach(field => {
const credKey = `credential_${field.field}`;
if (formData[credKey]) {
credentials[field.field] = formData[credKey];
}
});
// Build request payload
const payload = {
tenant_id: tenantId,
provider: formData.provider,
config_name: formData.config_name,
credentials,
sync_settings: {
auto_sync_enabled: formData.auto_sync_enabled === 'true' || formData.auto_sync_enabled === true,
sync_interval_minutes: parseInt(formData.sync_interval_minutes),
sync_sales: formData.sync_sales === 'true' || formData.sync_sales === true,
sync_inventory: formData.sync_inventory === 'true' || formData.sync_inventory === true,
sync_customers: false
}
};
// Create or update configuration
if (mode === 'edit' && existingConfig) {
await posService.updatePOSConfiguration({
...payload,
config_id: existingConfig.id
});
addToast('Configuración actualizada correctamente', { type: 'success' });
} else {
await posService.createPOSConfiguration(payload);
addToast('Configuración creada correctamente', { type: 'success' });
}
onSuccess?.();
onClose();
} catch (error: any) {
console.error('Error saving POS configuration:', error);
addToast(error?.message || 'Error al guardar la configuración', { type: 'error' });
throw error; // Let AddModal handle error state
} finally {
setLoading(false);
}
};
return (
<AddModal
isOpen={isOpen}
onClose={onClose}
title={mode === 'edit' ? 'Editar Sistema POS' : 'Agregar Sistema POS'}
subtitle={mode === 'edit' ? 'Actualiza la configuración del sistema POS' : 'Configura un nuevo sistema POS para sincronizar ventas e inventario'}
statusIndicator={{
color: statusColors.inProgress.primary,
text: mode === 'edit' ? 'Edición' : 'Nueva Configuración',
icon: Zap,
isCritical: false,
isHighlight: true
}}
sections={sections}
onSave={handleSave}
loading={loading}
size="xl"
initialData={initialData}
validationErrors={{}}
onValidationError={(errors) => {
// Custom validation if needed
if (errors && Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
addToast(firstError, { type: 'error' });
}
}}
/>
);
};
export default CreatePOSConfigModal;

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { ShoppingCart, Plus, Minus, Trash2, X } from 'lucide-react';
import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
category: string;
stock: number;
}
interface POSCartProps {
cart: CartItem[];
onUpdateQuantity: (id: string, quantity: number) => void;
onClearCart: () => void;
taxRate?: number;
}
/**
* POSCart - Fixed sidebar cart component with clear totals and item management
* Optimized for quick checkout operations
*/
export const POSCart: React.FC<POSCartProps> = ({
cart,
onUpdateQuantity,
onClearCart,
taxRate = 0.21, // 21% IVA by default
}) => {
// Calculate totals
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * taxRate;
const total = subtotal + tax;
const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
return (
<div className="h-full flex flex-col">
{/* Cart Header */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--border-primary)]">
<h3 className="text-xl font-bold text-[var(--text-primary)] flex items-center gap-2">
<ShoppingCart className="w-6 h-6 text-[var(--color-primary)]" />
Carrito ({itemCount})
</h3>
{cart.length > 0 && (
<Button variant="ghost" size="sm" onClick={onClearCart}>
<Trash2 className="w-4 h-4 mr-1" />
Limpiar
</Button>
)}
</div>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto space-y-3 mb-4">
{cart.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<ShoppingCart className="w-16 h-16 text-[var(--text-tertiary)] mb-3 opacity-30" />
<p className="text-[var(--text-secondary)] font-medium">Carrito vacío</p>
<p className="text-sm text-[var(--text-tertiary)] mt-1">
Agrega productos para comenzar
</p>
</div>
) : (
cart.map((item) => (
<Card
key={item.id}
className="p-3 bg-[var(--bg-secondary)] border-l-4 transition-all hover:shadow-md"
style={{
borderLeftColor: 'var(--color-primary)',
}}
>
<div className="flex items-start justify-between gap-3">
{/* Item Info */}
<div className="flex-1 min-w-0">
<h4
className="text-sm font-semibold text-[var(--text-primary)] truncate"
title={item.name}
>
{item.name}
</h4>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-sm font-medium text-[var(--color-primary)]">
{item.price.toFixed(2)}
</span>
<span className="text-xs text-[var(--text-tertiary)]">c/u</span>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Stock: {item.stock}
</p>
</div>
{/* Remove Button */}
<button
onClick={() => onUpdateQuantity(item.id, 0)}
className="text-[var(--color-error)] hover:bg-[var(--color-error)]/10 p-1 rounded transition-colors"
title="Eliminar"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Quantity Controls */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 p-0"
>
<Minus className="w-3 h-3" />
</Button>
<span className="w-10 text-center text-base font-bold text-[var(--text-primary)]">
{item.quantity}
</span>
<Button
size="sm"
variant="outline"
onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}
disabled={item.quantity >= item.stock}
className="w-8 h-8 p-0"
>
<Plus className="w-3 h-3" />
</Button>
</div>
{/* Item Subtotal */}
<div className="text-right">
<p className="text-base font-bold text-[var(--text-primary)]">
{(item.price * item.quantity).toFixed(2)}
</p>
</div>
</div>
</Card>
))
)}
</div>
{/* Cart Totals */}
{cart.length > 0 && (
<Card className="p-4 bg-[var(--bg-tertiary)] border-2 border-[var(--color-primary)]/20">
<div className="space-y-3">
{/* Subtotal */}
<div className="flex justify-between items-center text-sm">
<span className="text-[var(--text-secondary)]">Subtotal:</span>
<span className="font-semibold text-[var(--text-primary)]">
{subtotal.toFixed(2)}
</span>
</div>
{/* Tax */}
<div className="flex justify-between items-center text-sm">
<span className="text-[var(--text-secondary)]">IVA ({(taxRate * 100).toFixed(0)}%):</span>
<span className="font-semibold text-[var(--text-primary)]">
{tax.toFixed(2)}
</span>
</div>
{/* Divider */}
<div className="border-t-2 border-[var(--border-secondary)] pt-3">
{/* Total */}
<div className="flex justify-between items-center">
<span className="text-lg font-bold text-[var(--text-primary)]">TOTAL:</span>
<span className="text-2xl font-bold text-[var(--color-primary)]">
{total.toFixed(2)}
</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
};
export default POSCart;

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { CreditCard, Banknote, ArrowRightLeft, Receipt, User } from 'lucide-react';
import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
interface CustomerInfo {
name: string;
email: string;
phone: string;
}
interface POSPaymentProps {
total: number;
onProcessPayment: (paymentData: {
paymentMethod: 'cash' | 'card' | 'transfer';
cashReceived?: number;
change?: number;
customerInfo?: CustomerInfo;
}) => void;
disabled?: boolean;
}
/**
* POSPayment - Color-coded payment section with customer info
* Optimized for quick checkout with visual payment method selection
*/
export const POSPayment: React.FC<POSPaymentProps> = ({
total,
onProcessPayment,
disabled = false,
}) => {
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState('');
const [customerInfo, setCustomerInfo] = useState<CustomerInfo>({
name: '',
email: '',
phone: '',
});
const [showCustomerForm, setShowCustomerForm] = useState(false);
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
const canProcessPayment =
!disabled &&
(paymentMethod === 'card' ||
paymentMethod === 'transfer' ||
(paymentMethod === 'cash' && cashReceived && parseFloat(cashReceived) >= total));
const handleProcessPayment = () => {
if (!canProcessPayment) return;
onProcessPayment({
paymentMethod,
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
change: paymentMethod === 'cash' ? change : undefined,
customerInfo: showCustomerForm ? customerInfo : undefined,
});
// Reset form
setCashReceived('');
setCustomerInfo({ name: '', email: '', phone: '' });
setShowCustomerForm(false);
};
// Payment method configurations with colors
const paymentMethods = [
{
id: 'cash' as const,
name: 'Efectivo',
icon: Banknote,
color: 'var(--color-success)',
bgColor: 'var(--color-success-light)',
borderColor: 'var(--color-success-dark)',
},
{
id: 'card' as const,
name: 'Tarjeta',
icon: CreditCard,
color: 'var(--color-info)',
bgColor: 'var(--color-info-light)',
borderColor: 'var(--color-info-dark)',
},
{
id: 'transfer' as const,
name: 'Transferencia',
icon: ArrowRightLeft,
color: 'var(--color-secondary)',
bgColor: 'var(--color-secondary-light)',
borderColor: 'var(--color-secondary-dark)',
},
];
return (
<div className="space-y-4">
{/* Customer Info Toggle */}
<Card className="p-4">
<button
onClick={() => setShowCustomerForm(!showCustomerForm)}
className="w-full flex items-center justify-between text-left"
>
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-[var(--color-primary)]" />
<span className="font-semibold text-[var(--text-primary)]">
Cliente (Opcional)
</span>
</div>
<span className="text-[var(--text-tertiary)]">
{showCustomerForm ? '▼' : '▶'}
</span>
</button>
{showCustomerForm && (
<div className="mt-4 space-y-3">
<Input
placeholder="Nombre"
value={customerInfo.name}
onChange={(e) =>
setCustomerInfo((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Input
placeholder="Email"
type="email"
value={customerInfo.email}
onChange={(e) =>
setCustomerInfo((prev) => ({ ...prev, email: e.target.value }))
}
/>
<Input
placeholder="Teléfono"
value={customerInfo.phone}
onChange={(e) =>
setCustomerInfo((prev) => ({ ...prev, phone: e.target.value }))
}
/>
</div>
)}
</Card>
{/* Payment Method Selection */}
<Card className="p-4">
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-4">
Método de Pago
</h3>
<div className="grid grid-cols-1 gap-3">
{paymentMethods.map((method) => {
const Icon = method.icon;
const isSelected = paymentMethod === method.id;
return (
<button
key={method.id}
onClick={() => setPaymentMethod(method.id)}
className={`
relative p-4 rounded-xl transition-all duration-200
border-2 font-semibold text-left
hover:scale-[1.02] active:scale-[0.98]
${
isSelected
? 'shadow-lg ring-4 ring-opacity-30'
: 'shadow hover:shadow-md'
}
`}
style={{
backgroundColor: isSelected ? method.bgColor : 'var(--bg-secondary)',
borderColor: isSelected ? method.borderColor : 'var(--border-secondary)',
color: isSelected ? method.color : 'var(--text-primary)',
...(isSelected && {
ringColor: method.color,
}),
}}
>
<div className="flex items-center gap-3">
<Icon className="w-6 h-6" />
<span className="text-base">{method.name}</span>
{isSelected && (
<span className="ml-auto text-2xl"></span>
)}
</div>
</button>
);
})}
</div>
{/* Cash Input */}
{paymentMethod === 'cash' && (
<div className="mt-4 space-y-3">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Efectivo Recibido
</label>
<Input
type="number"
step="0.01"
placeholder="€0.00"
value={cashReceived}
onChange={(e) => setCashReceived(e.target.value)}
className="text-lg font-semibold"
/>
</div>
{/* Change Display */}
{cashReceived && parseFloat(cashReceived) >= total && (
<Card
className="p-4 border-2"
style={{
backgroundColor: 'var(--color-success-light)',
borderColor: 'var(--color-success)',
}}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: 'var(--color-success-dark)' }}>
Cambio:
</span>
<span className="text-2xl font-bold" style={{ color: 'var(--color-success-dark)' }}>
{change.toFixed(2)}
</span>
</div>
</Card>
)}
{/* Insufficient Cash Warning */}
{cashReceived && parseFloat(cashReceived) < total && (
<Card
className="p-3 border-2"
style={{
backgroundColor: 'var(--color-warning-light)',
borderColor: 'var(--color-warning)',
}}
>
<p className="text-sm font-medium text-center" style={{ color: 'var(--color-warning-dark)' }}>
Efectivo insuficiente: falta {(total - parseFloat(cashReceived)).toFixed(2)}
</p>
</Card>
)}
</div>
)}
</Card>
{/* Process Payment Button */}
<Button
onClick={handleProcessPayment}
disabled={!canProcessPayment}
variant="primary"
size="lg"
className="w-full text-lg font-bold py-6 shadow-lg hover:shadow-xl transition-all"
>
<Receipt className="w-6 h-6 mr-2" />
Procesar Venta - {total.toFixed(2)}
</Button>
</div>
);
};
export default POSPayment;

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { Plus, Package } from 'lucide-react';
import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
interface POSProductCardProps {
id: string;
name: string;
price: number;
category: string;
stock: number;
cartQuantity?: number;
onAddToCart: () => void;
onClick?: () => void;
}
/**
* POSProductCard - Large, touch-friendly product card optimized for POS operations
* Designed for 80cm+ viewing distance with clear visual hierarchy
*/
export const POSProductCard: React.FC<POSProductCardProps> = ({
name,
price,
category,
stock,
cartQuantity = 0,
onAddToCart,
onClick,
}) => {
const remainingStock = stock - cartQuantity;
const isOutOfStock = remainingStock <= 0;
const isLowStock = remainingStock > 0 && remainingStock <= 5;
// Stock status configuration
const getStockConfig = () => {
if (isOutOfStock) {
return {
color: 'var(--color-error)',
bgColor: 'var(--color-error-light)',
text: 'Sin Stock',
icon: '🚫',
};
} else if (isLowStock) {
return {
color: 'var(--color-warning)',
bgColor: 'var(--color-warning-light)',
text: `${remainingStock} disponibles`,
icon: '⚠️',
};
} else {
return {
color: 'var(--color-success)',
bgColor: 'var(--color-success-light)',
text: `${remainingStock} disponibles`,
icon: '✓',
};
}
};
const stockConfig = getStockConfig();
return (
<Card
className={`
relative overflow-hidden transition-all duration-200 hover:shadow-xl
${isOutOfStock ? 'opacity-60' : 'hover:scale-[1.02]'}
${onClick ? 'cursor-pointer' : ''}
`}
onClick={onClick}
>
<div className="p-4 sm:p-6 space-y-3">
{/* Product Image Placeholder with Category Icon */}
<div
className="w-full h-32 sm:h-40 rounded-xl flex items-center justify-center mb-3"
style={{
backgroundColor: 'var(--bg-tertiary)',
border: '2px dashed var(--border-secondary)',
}}
>
<Package className="w-12 h-12 sm:w-16 sm:h-16 text-[var(--text-tertiary)]" />
</div>
{/* Product Name */}
<div className="space-y-1">
<h3
className="text-base sm:text-lg font-bold text-[var(--text-primary)] truncate"
title={name}
>
{name}
</h3>
<p className="text-xs sm:text-sm text-[var(--text-secondary)] capitalize">
{category}
</p>
</div>
{/* Price - Large and prominent */}
<div className="flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold text-[var(--color-primary)]">
{price.toFixed(2)}
</span>
<span className="text-sm text-[var(--text-tertiary)]">c/u</span>
</div>
{/* Stock Status Badge */}
<div
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold"
style={{
backgroundColor: stockConfig.bgColor,
color: stockConfig.color,
}}
>
<span>{stockConfig.icon}</span>
<span>{stockConfig.text}</span>
</div>
{/* In Cart Indicator */}
{cartQuantity > 0 && (
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" size="md">
En carrito: {cartQuantity}
</Badge>
</div>
)}
{/* Add to Cart Button - Large and prominent */}
<Button
onClick={(e) => {
e.stopPropagation();
onAddToCart();
}}
disabled={isOutOfStock}
variant="primary"
size="lg"
className="w-full mt-4 text-base sm:text-lg font-semibold py-3 sm:py-4"
>
<Plus className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{isOutOfStock ? 'Sin Stock' : 'Agregar'}
</Button>
</div>
{/* Out of Stock Overlay */}
{isOutOfStock && (
<div className="absolute inset-0 bg-[var(--bg-primary)] bg-opacity-50 flex items-center justify-center pointer-events-none">
<div className="bg-[var(--color-error)] text-white px-6 py-3 rounded-lg font-bold text-lg shadow-lg">
AGOTADO
</div>
</div>
)}
</Card>
);
};
export default POSProductCard;

View File

@@ -0,0 +1,4 @@
export { POSProductCard } from './POSProductCard';
export { POSCart } from './POSCart';
export { POSPayment } from './POSPayment';
export { CreatePOSConfigModal } from './CreatePOSConfigModal';

View File

@@ -5,6 +5,7 @@ import { useSuppliers } from '../../../api/hooks/suppliers';
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
import { useIngredients } from '../../../api/hooks/inventory';
import { useTenantStore } from '../../../stores/tenant.store';
import { suppliersService } from '../../../api/services/suppliers';
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
import type { SupplierSummary } from '../../../api/types/suppliers';
import type { IngredientResponse } from '../../../api/types/inventory';
@@ -31,6 +32,7 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
}) => {
const [loading, setLoading] = useState(false);
const [selectedSupplier, setSelectedSupplier] = useState<string>('');
const [formData, setFormData] = useState<Record<string, any>>({});
// Get current tenant
const { currentTenant } = useTenantStore();
@@ -44,13 +46,49 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
);
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
// Fetch ingredients filtered by selected supplier (only when manually adding products)
const { data: ingredientsData = [] } = useIngredients(
// State for supplier products
const [supplierProductIds, setSupplierProductIds] = useState<string[]>([]);
const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false);
// Fetch ALL ingredients (we'll filter client-side based on supplier products)
const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients(
tenantId,
selectedSupplier ? { supplier_id: selectedSupplier } : {},
{ enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier }
{},
{ enabled: !!tenantId && isOpen && !requirements?.length }
);
// Fetch supplier products when supplier is selected
useEffect(() => {
const fetchSupplierProducts = async () => {
if (!selectedSupplier || !tenantId) {
setSupplierProductIds([]);
return;
}
setIsLoadingSupplierProducts(true);
try {
const products = await suppliersService.getSupplierProducts(tenantId, selectedSupplier);
const productIds = products.map(p => p.inventory_product_id);
setSupplierProductIds(productIds);
} catch (error) {
console.error('Error fetching supplier products:', error);
setSupplierProductIds([]);
} finally {
setIsLoadingSupplierProducts(false);
}
};
fetchSupplierProducts();
}, [selectedSupplier, tenantId]);
// Filter ingredients based on supplier products
const ingredientsData = useMemo(() => {
if (!selectedSupplier || supplierProductIds.length === 0) {
return [];
}
return allIngredientsData.filter(ing => supplierProductIds.includes(ing.id));
}, [allIngredientsData, supplierProductIds, selectedSupplier]);
// Create purchase order mutation
const createPurchaseOrderMutation = useCreatePurchaseOrder();
@@ -66,6 +104,14 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
data: ingredient // Store full ingredient data for later use
})), [ingredientsData]);
// Reset selected supplier when modal closes
useEffect(() => {
if (!isOpen) {
setSelectedSupplier('');
setFormData({});
}
}, [isOpen]);
// Unit options for select field
const unitOptions = [
{ value: 'kg', label: 'Kilogramos' },
@@ -80,11 +126,6 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
const handleSave = async (formData: Record<string, any>) => {
setLoading(true);
// Update selectedSupplier if it changed
if (formData.supplier_id && formData.supplier_id !== selectedSupplier) {
setSelectedSupplier(formData.supplier_id);
}
try {
let items: PurchaseOrderItem[] = [];
@@ -187,8 +228,9 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
};
const sections = [
{
// Build sections dynamically based on selectedSupplier
const sections = useMemo(() => {
const supplierSection = {
title: 'Información del Proveedor',
icon: Building2,
fields: [
@@ -199,11 +241,19 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
required: true,
options: supplierOptions,
placeholder: 'Seleccionar proveedor...',
span: 2
span: 2,
validation: (value: any) => {
// Update selectedSupplier when supplier changes
if (value && value !== selectedSupplier) {
setTimeout(() => setSelectedSupplier(value), 0);
}
return null;
}
}
]
},
{
};
const orderDetailsSection = {
title: 'Detalles de la Orden',
icon: Calendar,
fields: [
@@ -222,8 +272,9 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
helpText: 'Información adicional o instrucciones especiales'
}
]
},
{
};
const ingredientsSection = {
title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar',
icon: Package,
fields: [
@@ -281,7 +332,7 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
},
helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos'
} : {
label: 'Productos a Comprar',
label: selectedSupplier ? 'Productos a Comprar' : 'Selecciona un proveedor primero',
name: 'manual_products',
type: 'list' as const,
span: 2,
@@ -294,8 +345,8 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
type: 'select',
required: true,
options: ingredientOptions,
placeholder: 'Seleccionar ingrediente...',
disabled: false
placeholder: isLoadingSupplierProducts || isLoadingIngredients ? 'Cargando ingredientes...' : ingredientOptions.length === 0 ? 'No hay ingredientes disponibles para este proveedor' : 'Seleccionar ingrediente...',
disabled: !selectedSupplier || isLoadingIngredients || isLoadingSupplierProducts
},
{
name: 'quantity',
@@ -322,16 +373,26 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
}
],
addButtonLabel: 'Agregar Ingrediente',
emptyStateText: 'No hay ingredientes disponibles para este proveedor',
emptyStateText: !selectedSupplier
? 'Selecciona un proveedor para agregar ingredientes'
: isLoadingSupplierProducts || isLoadingIngredients
? 'Cargando ingredientes del proveedor...'
: ingredientOptions.length === 0
? 'Este proveedor no tiene ingredientes asignados en su lista de precios'
: 'No hay ingredientes agregados',
showSubtotals: true,
subtotalFields: { quantity: 'quantity', price: 'unit_price' },
disabled: !selectedSupplier
},
helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado'
helpText: !selectedSupplier
? 'Primero selecciona un proveedor en la sección anterior'
: 'Selecciona ingredientes disponibles del proveedor seleccionado'
}
]
},
];
};
return [supplierSection, orderDetailsSection, ingredientsSection];
}, [requirements, supplierOptions, ingredientOptions, selectedSupplier, isLoadingIngredients, unitOptions]);
return (
<>