Improve the frontend
This commit is contained in:
126
frontend/src/components/domain/dashboard/AlertBulkActions.tsx
Normal file
126
frontend/src/components/domain/dashboard/AlertBulkActions.tsx
Normal 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;
|
||||
446
frontend/src/components/domain/dashboard/AlertCard.tsx
Normal file
446
frontend/src/components/domain/dashboard/AlertCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
306
frontend/src/components/domain/dashboard/AlertFilters.tsx
Normal file
306
frontend/src/components/domain/dashboard/AlertFilters.tsx
Normal 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;
|
||||
@@ -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;
|
||||
118
frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx
Normal file
118
frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx
Normal 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;
|
||||
179
frontend/src/components/domain/dashboard/AlertTrends.tsx
Normal file
179
frontend/src/components/domain/dashboard/AlertTrends.tsx
Normal 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;
|
||||
425
frontend/src/components/domain/dashboard/PendingPOApprovals.tsx
Normal file
425
frontend/src/components/domain/dashboard/PendingPOApprovals.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal file
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
325
frontend/src/components/domain/pos/CreatePOSConfigModal.tsx
Normal file
325
frontend/src/components/domain/pos/CreatePOSConfigModal.tsx
Normal 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;
|
||||
177
frontend/src/components/domain/pos/POSCart.tsx
Normal file
177
frontend/src/components/domain/pos/POSCart.tsx
Normal 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;
|
||||
256
frontend/src/components/domain/pos/POSPayment.tsx
Normal file
256
frontend/src/components/domain/pos/POSPayment.tsx
Normal 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;
|
||||
154
frontend/src/components/domain/pos/POSProductCard.tsx
Normal file
154
frontend/src/components/domain/pos/POSProductCard.tsx
Normal 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;
|
||||
4
frontend/src/components/domain/pos/index.ts
Normal file
4
frontend/src/components/domain/pos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { POSProductCard } from './POSProductCard';
|
||||
export { POSCart } from './POSCart';
|
||||
export { POSPayment } from './POSPayment';
|
||||
export { CreatePOSConfigModal } from './CreatePOSConfigModal';
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user