Add a ne model and card design across pages
This commit is contained in:
@@ -125,6 +125,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get navigation routes from config and convert to navigation items - memoized
|
// Get navigation routes from config and convert to navigation items - memoized
|
||||||
@@ -302,11 +303,59 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Render submenu overlay for collapsed sidebar
|
||||||
|
const renderSubmenuOverlay = (item: NavigationItem) => {
|
||||||
|
if (!item.children || item.children.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-[var(--sidebar-collapsed-width)] top-0 z-[var(--z-popover)] min-w-[200px] bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2">
|
||||||
|
<div className="px-3 py-2 border-b border-[var(--border-primary)]">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="py-1">
|
||||||
|
{item.children.map(child => (
|
||||||
|
<li key={child.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleItemClick(child)}
|
||||||
|
disabled={child.disabled}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-left px-3 py-2 text-sm transition-colors duration-200',
|
||||||
|
'hover:bg-[var(--bg-secondary)]',
|
||||||
|
location.pathname === child.path && 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] border-r-2 border-[var(--color-primary)]',
|
||||||
|
child.disabled && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{child.icon && (
|
||||||
|
<child.icon className="w-4 h-4 mr-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{child.label}</span>
|
||||||
|
{child.badge && (
|
||||||
|
<Badge
|
||||||
|
variant={child.badge.variant || 'default'}
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-xs"
|
||||||
|
>
|
||||||
|
{child.badge.text}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Render navigation item
|
// Render navigation item
|
||||||
const renderItem = (item: NavigationItem, level = 0) => {
|
const renderItem = (item: NavigationItem, level = 0) => {
|
||||||
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
||||||
const isExpanded = expandedItems.has(item.id);
|
const isExpanded = expandedItems.has(item.id);
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
const isHovered = hoveredItem === item.id;
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
|
|
||||||
const itemContent = (
|
const itemContent = (
|
||||||
@@ -317,17 +366,24 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
level > 0 && 'pl-6',
|
level > 0 && 'pl-6',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{ItemIcon && (
|
<div className="relative">
|
||||||
<ItemIcon
|
{ItemIcon && (
|
||||||
className={clsx(
|
<ItemIcon
|
||||||
'flex-shrink-0 transition-colors duration-200',
|
className={clsx(
|
||||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
'flex-shrink-0 transition-colors duration-200',
|
||||||
isActive
|
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||||
? 'text-[var(--color-primary)]'
|
isActive
|
||||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
? 'text-[var(--color-primary)]'
|
||||||
)}
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submenu indicator for collapsed sidebar */}
|
||||||
|
{isCollapsed && hasChildren && level === 0 && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full opacity-75" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!ItemIcon && level > 0 && (
|
{!ItemIcon && level > 0 && (
|
||||||
<Dot className={clsx(
|
<Dot className={clsx(
|
||||||
@@ -374,24 +430,47 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => handleItemClick(item)}
|
<button
|
||||||
disabled={item.disabled}
|
onClick={() => handleItemClick(item)}
|
||||||
data-path={item.path}
|
disabled={item.disabled}
|
||||||
className={clsx(
|
data-path={item.path}
|
||||||
'w-full rounded-lg transition-all duration-200',
|
onMouseEnter={() => {
|
||||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
if (isCollapsed && hasChildren && level === 0) {
|
||||||
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
setHoveredItem(item.id);
|
||||||
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
}
|
||||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
}}
|
||||||
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
onMouseLeave={() => {
|
||||||
|
if (isCollapsed && hasChildren && level === 0) {
|
||||||
|
setHoveredItem(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded-lg transition-all duration-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||||
|
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
||||||
|
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
||||||
|
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
||||||
|
)}
|
||||||
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
{itemContent}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Submenu overlay for collapsed sidebar */}
|
||||||
|
{isCollapsed && hasChildren && level === 0 && isHovered && (
|
||||||
|
<div
|
||||||
|
className="absolute left-full top-0 ml-2 z-[var(--z-popover)]"
|
||||||
|
onMouseEnter={() => setHoveredItem(item.id)}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
>
|
||||||
|
{renderSubmenuOverlay(item)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
</div>
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
title={isCollapsed ? item.label : undefined}
|
|
||||||
>
|
|
||||||
{itemContent}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -116,38 +116,38 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
xs: 'max-w-xs',
|
xs: 'max-w-xs w-full',
|
||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm w-full',
|
||||||
md: 'max-w-md',
|
md: 'max-w-md w-full',
|
||||||
lg: 'max-w-lg',
|
lg: 'max-w-lg w-full max-h-[90vh] overflow-y-auto',
|
||||||
xl: 'max-w-xl',
|
xl: 'max-w-xl w-full max-h-[90vh] overflow-y-auto',
|
||||||
'2xl': 'max-w-2xl',
|
'2xl': 'max-w-2xl w-full max-h-[90vh] overflow-y-auto',
|
||||||
full: 'max-w-full',
|
full: 'max-w-full w-full max-h-[90vh] overflow-y-auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: {
|
default: {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||||
},
|
},
|
||||||
centered: {
|
centered: {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||||
},
|
},
|
||||||
'drawer-left': {
|
'drawer-left': {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-start',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex justify-start',
|
||||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||||
},
|
},
|
||||||
'drawer-right': {
|
'drawer-right': {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-end',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex justify-end',
|
||||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||||
},
|
},
|
||||||
'drawer-top': {
|
'drawer-top': {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-start',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col justify-start',
|
||||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||||
},
|
},
|
||||||
'drawer-bottom': {
|
'drawer-bottom': {
|
||||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-end',
|
overlay: 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col justify-end',
|
||||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -159,7 +159,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contentClasses = clsx(
|
const contentClasses = clsx(
|
||||||
'relative bg-modal-bg border border-modal-border rounded-lg shadow-xl',
|
'relative bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl',
|
||||||
'animate-in zoom-in-95 duration-200',
|
'animate-in zoom-in-95 duration-200',
|
||||||
variantClasses[variant].content,
|
variantClasses[variant].content,
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
@@ -192,7 +192,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
className="absolute top-4 right-4 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 rounded-md p-1"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Cerrar modal"
|
aria-label="Cerrar modal"
|
||||||
>
|
>
|
||||||
@@ -225,7 +225,7 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
|||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const classes = clsx(
|
const classes = clsx(
|
||||||
'px-6 py-4 border-b border-border-primary',
|
'px-6 py-4 border-b border-[var(--border-primary)]',
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -240,13 +240,13 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
|||||||
{title && (
|
{title && (
|
||||||
<h2
|
<h2
|
||||||
id="modal-title"
|
id="modal-title"
|
||||||
className="text-lg font-semibold text-text-primary"
|
className="text-lg font-semibold text-[var(--text-primary)]"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="mt-1 text-sm text-text-secondary">
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -255,7 +255,7 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
|||||||
{showCloseButton && onClose && (
|
{showCloseButton && onClose && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
className="ml-4 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 rounded-md p-1"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Cerrar modal"
|
aria-label="Cerrar modal"
|
||||||
>
|
>
|
||||||
@@ -333,7 +333,7 @@ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const classes = clsx(
|
const classes = clsx(
|
||||||
'flex items-center gap-3 border-t border-border-primary',
|
'flex items-center gap-3 border-t border-[var(--border-primary)]',
|
||||||
paddingClasses[padding],
|
paddingClasses[padding],
|
||||||
justifyClasses[justify],
|
justifyClasses[justify],
|
||||||
className
|
className
|
||||||
|
|||||||
226
frontend/src/components/ui/StatusCard/StatusCard.tsx
Normal file
226
frontend/src/components/ui/StatusCard/StatusCard.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { Card } from '../Card';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { statusColors } from '../../../styles/colors';
|
||||||
|
|
||||||
|
export interface StatusIndicatorConfig {
|
||||||
|
color: string;
|
||||||
|
text: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
isCritical?: boolean;
|
||||||
|
isHighlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusCardProps {
|
||||||
|
id: string;
|
||||||
|
statusIndicator: StatusIndicatorConfig;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
primaryValue: string | number;
|
||||||
|
primaryValueLabel?: string;
|
||||||
|
secondaryInfo?: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
progress?: {
|
||||||
|
label: string;
|
||||||
|
percentage: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
metadata?: string[];
|
||||||
|
actions?: Array<{
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: 'primary' | 'outline';
|
||||||
|
onClick: () => void;
|
||||||
|
}>;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color configuration from the global palette
|
||||||
|
*/
|
||||||
|
export const getStatusColor = (status: string): StatusIndicatorConfig['color'] => {
|
||||||
|
// Normalize status key (handle different formats)
|
||||||
|
const normalizedStatus = status.toLowerCase().replace(/[_-]/g, '').replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// Map common status variations to our palette
|
||||||
|
const statusMap: { [key: string]: keyof typeof statusColors } = {
|
||||||
|
'pending': 'pending',
|
||||||
|
'inprogress': 'inProgress',
|
||||||
|
'completed': 'completed',
|
||||||
|
'cancelled': 'cancelled',
|
||||||
|
'normal': 'normal',
|
||||||
|
'low': 'low',
|
||||||
|
'out': 'out',
|
||||||
|
'expired': 'expired',
|
||||||
|
'approved': 'approved',
|
||||||
|
'intransit': 'inTransit',
|
||||||
|
'delivered': 'delivered',
|
||||||
|
'bread': 'bread',
|
||||||
|
'pastry': 'pastry',
|
||||||
|
'cake': 'cake',
|
||||||
|
'cookie': 'cookie',
|
||||||
|
'other': 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedStatus = statusMap[normalizedStatus];
|
||||||
|
return mappedStatus ? statusColors[mappedStatus].primary : statusColors.other.primary;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusCard - Reusable card component with consistent status styling
|
||||||
|
*/
|
||||||
|
export const StatusCard: React.FC<StatusCardProps> = ({
|
||||||
|
id,
|
||||||
|
statusIndicator,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
primaryValue,
|
||||||
|
primaryValueLabel,
|
||||||
|
secondaryInfo,
|
||||||
|
progress,
|
||||||
|
metadata = [],
|
||||||
|
actions = [],
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const StatusIcon = statusIndicator.icon;
|
||||||
|
const hasInteraction = onClick || actions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`
|
||||||
|
p-5 transition-all duration-200 border-l-4
|
||||||
|
${hasInteraction ? 'hover:shadow-md cursor-pointer' : ''}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: statusIndicator.color,
|
||||||
|
backgroundColor: statusIndicator.isCritical
|
||||||
|
? `${statusIndicator.color}08`
|
||||||
|
: statusIndicator.isHighlight
|
||||||
|
? `${statusIndicator.color}05`
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with status indicator */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||||
|
>
|
||||||
|
{StatusIcon && (
|
||||||
|
<StatusIcon
|
||||||
|
className="w-4 h-4"
|
||||||
|
style={{ color: statusIndicator.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[var(--text-primary)] text-base">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: statusIndicator.color }}
|
||||||
|
>
|
||||||
|
{statusIndicator.text}
|
||||||
|
{statusIndicator.isCritical && (
|
||||||
|
<span className="ml-2 text-xs">⚠️</span>
|
||||||
|
)}
|
||||||
|
{statusIndicator.isHighlight && (
|
||||||
|
<span className="ml-2 text-xs">⭐</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{primaryValue}
|
||||||
|
</div>
|
||||||
|
{primaryValueLabel && (
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">
|
||||||
|
{primaryValueLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary info */}
|
||||||
|
{secondaryInfo && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{secondaryInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{secondaryInfo.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
{progress && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||||
|
{progress.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{progress.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progress.percentage, 100)}%`,
|
||||||
|
backgroundColor: progress.color || statusIndicator.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{metadata.length > 0 && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
|
{metadata.map((item, index) => (
|
||||||
|
<div key={index}>{item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="flex gap-2 pt-3 border-t border-[var(--border-primary)]">
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={action.variant || 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusCard;
|
||||||
2
frontend/src/components/ui/StatusCard/index.ts
Normal file
2
frontend/src/components/ui/StatusCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { StatusCard, getStatusColor } from './StatusCard';
|
||||||
|
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
|
||||||
428
frontend/src/components/ui/StatusModal/StatusModal.tsx
Normal file
428
frontend/src/components/ui/StatusModal/StatusModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LucideIcon, Edit, Eye, X } from 'lucide-react';
|
||||||
|
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
|
||||||
|
import { formatters } from '../Stats/StatsPresets';
|
||||||
|
|
||||||
|
export interface StatusModalField {
|
||||||
|
label: string;
|
||||||
|
value: string | number | React.ReactNode;
|
||||||
|
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number';
|
||||||
|
highlight?: boolean;
|
||||||
|
span?: 1 | 2; // For grid layout
|
||||||
|
editable?: boolean; // Whether this field can be edited
|
||||||
|
required?: boolean; // Whether this field is required
|
||||||
|
placeholder?: string; // Placeholder text for inputs
|
||||||
|
options?: Array<{label: string; value: string | number}>; // For select fields
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModalSection {
|
||||||
|
title: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
fields: StatusModalField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModalAction {
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
onModeChange?: (mode: 'view' | 'edit') => void;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
statusIndicator?: StatusIndicatorConfig;
|
||||||
|
image?: string;
|
||||||
|
sections: StatusModalSection[];
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
actions?: StatusModalAction[];
|
||||||
|
showDefaultActions?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onSave?: () => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format field value based on type
|
||||||
|
*/
|
||||||
|
const formatFieldValue = (value: string | number | React.ReactNode, type: StatusModalField['type'] = 'text'): React.ReactNode => {
|
||||||
|
if (React.isValidElement(value)) return value;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'currency':
|
||||||
|
return formatters.currency(Number(value));
|
||||||
|
case 'date':
|
||||||
|
return new Date(String(value)).toLocaleDateString('es-ES');
|
||||||
|
case 'datetime':
|
||||||
|
return new Date(String(value)).toLocaleString('es-ES');
|
||||||
|
case 'percentage':
|
||||||
|
return `${value}%`;
|
||||||
|
case 'list':
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<li key={index} className="text-sm">{String(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
case 'status':
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${getStatusColor(String(value))}20`,
|
||||||
|
color: getStatusColor(String(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={String(value)}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 rounded-lg object-cover bg-[var(--bg-secondary)]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render editable field based on type and mode
|
||||||
|
*/
|
||||||
|
const renderEditableField = (
|
||||||
|
field: StatusModalField,
|
||||||
|
isEditMode: boolean,
|
||||||
|
onChange?: (value: string | number) => void
|
||||||
|
): React.ReactNode => {
|
||||||
|
if (!isEditMode || !field.editable) {
|
||||||
|
return formatFieldValue(field.value, field.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||||
|
onChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputValue = field.type === 'currency' ? Number(String(field.value).replace(/[^0-9.-]+/g, '')) : field.value;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'email':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={String(inputValue)}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'tel':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
value={String(inputValue)}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'number':
|
||||||
|
case 'currency':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={String(inputValue)}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
step={field.type === 'currency' ? '0.01' : '1'}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'date':
|
||||||
|
const dateValue = field.value ? new Date(String(field.value)).toISOString().split('T')[0] : '';
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'list':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
||||||
|
onChange={(e) => onChange?.(e.target.value.split('\n'))}
|
||||||
|
placeholder={field.placeholder || 'Una opción por línea'}
|
||||||
|
required={field.required}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={String(inputValue)}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusModal - Unified modal component for viewing/editing card details
|
||||||
|
* Follows UX best practices for modal dialogs (2024)
|
||||||
|
*/
|
||||||
|
export const StatusModal: React.FC<StatusModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
statusIndicator,
|
||||||
|
image,
|
||||||
|
sections,
|
||||||
|
actions = [],
|
||||||
|
showDefaultActions = true,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
size = 'lg',
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const StatusIcon = statusIndicator?.icon;
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (onModeChange) {
|
||||||
|
onModeChange('edit');
|
||||||
|
} else if (onEdit) {
|
||||||
|
onEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (onSave) {
|
||||||
|
await onSave();
|
||||||
|
}
|
||||||
|
if (onModeChange) {
|
||||||
|
onModeChange('view');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
if (onModeChange) {
|
||||||
|
onModeChange('view');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default actions based on mode
|
||||||
|
const defaultActions: StatusModalAction[] = [];
|
||||||
|
|
||||||
|
if (showDefaultActions) {
|
||||||
|
if (mode === 'view') {
|
||||||
|
defaultActions.push({
|
||||||
|
label: 'Editar',
|
||||||
|
icon: Edit,
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: handleEdit,
|
||||||
|
disabled: loading,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
defaultActions.push(
|
||||||
|
{
|
||||||
|
label: 'Cancelar',
|
||||||
|
variant: 'outline',
|
||||||
|
onClick: handleCancel,
|
||||||
|
disabled: loading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Guardar',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: handleSave,
|
||||||
|
disabled: loading,
|
||||||
|
loading: loading,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allActions = [...actions, ...defaultActions];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size={size}
|
||||||
|
closeOnOverlayClick={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Status indicator */}
|
||||||
|
{statusIndicator && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||||
|
>
|
||||||
|
{StatusIcon && (
|
||||||
|
<StatusIcon
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ color: statusIndicator.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title and status */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{statusIndicator && (
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium mt-1"
|
||||||
|
style={{ color: statusIndicator.color }}
|
||||||
|
>
|
||||||
|
{statusIndicator.text}
|
||||||
|
{statusIndicator.isCritical && (
|
||||||
|
<span className="ml-2 text-xs">⚠️</span>
|
||||||
|
)}
|
||||||
|
{statusIndicator.isHighlight && (
|
||||||
|
<span className="ml-2 text-xs">⭐</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showCloseButton={true}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||||
|
<span className="text-[var(--text-secondary)]">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-48 object-cover rounded-lg bg-[var(--bg-secondary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sections.map((section, sectionIndex) => (
|
||||||
|
<div key={sectionIndex} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b border-[var(--border-primary)]">
|
||||||
|
{section.icon && (
|
||||||
|
<section.icon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
)}
|
||||||
|
<h3 className="font-medium text-[var(--text-primary)]">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{section.fields.map((field, fieldIndex) => (
|
||||||
|
<div
|
||||||
|
key={fieldIndex}
|
||||||
|
className={`space-y-1 ${field.span === 2 ? 'md:col-span-2' : ''}`}
|
||||||
|
>
|
||||||
|
<dt className="text-sm font-medium text-[var(--text-secondary)]">
|
||||||
|
{field.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={`text-sm ${
|
||||||
|
field.highlight
|
||||||
|
? 'font-semibold text-[var(--text-primary)]'
|
||||||
|
: 'text-[var(--text-primary)]'
|
||||||
|
}`}>
|
||||||
|
{renderEditableField(field, mode === 'edit')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
{allActions.length > 0 && (
|
||||||
|
<ModalFooter justify="end">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{allActions.map((action, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={action.variant || 'outline'}
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled || loading}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{action.loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
||||||
|
{action.label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusModal;
|
||||||
7
frontend/src/components/ui/StatusModal/index.ts
Normal file
7
frontend/src/components/ui/StatusModal/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { StatusModal } from './StatusModal';
|
||||||
|
export type {
|
||||||
|
StatusModalProps,
|
||||||
|
StatusModalField,
|
||||||
|
StatusModalSection,
|
||||||
|
StatusModalAction
|
||||||
|
} from './StatusModal';
|
||||||
@@ -14,6 +14,8 @@ export { ProgressBar } from './ProgressBar';
|
|||||||
export { StatusIndicator } from './StatusIndicator';
|
export { StatusIndicator } from './StatusIndicator';
|
||||||
export { ListItem } from './ListItem';
|
export { ListItem } from './ListItem';
|
||||||
export { StatsCard, StatsGrid } from './Stats';
|
export { StatsCard, StatsGrid } from './Stats';
|
||||||
|
export { StatusCard, getStatusColor } from './StatusCard';
|
||||||
|
export { StatusModal } from './StatusModal';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { ButtonProps } from './Button';
|
export type { ButtonProps } from './Button';
|
||||||
@@ -31,3 +33,5 @@ export type { ProgressBarProps } from './ProgressBar';
|
|||||||
export type { StatusIndicatorProps } from './StatusIndicator';
|
export type { StatusIndicatorProps } from './StatusIndicator';
|
||||||
export type { ListItemProps } from './ListItem';
|
export type { ListItemProps } from './ListItem';
|
||||||
export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps } from './Stats';
|
export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps } from './Stats';
|
||||||
|
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
|
||||||
|
export type { StatusModalProps, StatusModalField, StatusModalSection, StatusModalAction } from './StatusModal';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||||
@@ -8,6 +8,7 @@ import { InventoryForm, LowStockAlert } from '../../../../components/domain/inve
|
|||||||
const InventoryPage: React.FC = () => {
|
const InventoryPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
||||||
|
|
||||||
const mockInventoryItems = [
|
const mockInventoryItems = [
|
||||||
@@ -83,65 +84,46 @@ const InventoryPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStockStatusBadge = (item: typeof mockInventoryItems[0]) => {
|
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
|
||||||
const { currentStock, minStock, status } = item;
|
const { currentStock, minStock, status } = item;
|
||||||
|
|
||||||
if (status === 'expired') {
|
if (status === 'expired') {
|
||||||
return (
|
return {
|
||||||
<Badge
|
color: getStatusColor('expired'),
|
||||||
variant="error"
|
text: 'Caducado',
|
||||||
icon={<AlertTriangle size={12} />}
|
icon: AlertTriangle,
|
||||||
text="Caducado"
|
isCritical: true,
|
||||||
/>
|
isHighlight: false
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStock === 0) {
|
if (currentStock === 0) {
|
||||||
return (
|
return {
|
||||||
<Badge
|
color: getStatusColor('out'),
|
||||||
variant="error"
|
text: 'Sin Stock',
|
||||||
icon={<AlertTriangle size={12} />}
|
icon: AlertTriangle,
|
||||||
text="Sin Stock"
|
isCritical: true,
|
||||||
/>
|
isHighlight: false
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStock <= minStock) {
|
if (currentStock <= minStock) {
|
||||||
return (
|
return {
|
||||||
<Badge
|
color: getStatusColor('low'),
|
||||||
variant="warning"
|
text: 'Stock Bajo',
|
||||||
icon={<AlertTriangle size={12} />}
|
icon: AlertTriangle,
|
||||||
text="Stock Bajo"
|
isCritical: false,
|
||||||
/>
|
isHighlight: true
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<Badge
|
color: getStatusColor('normal'),
|
||||||
variant="success"
|
text: 'Normal',
|
||||||
icon={<CheckCircle size={12} />}
|
icon: CheckCircle,
|
||||||
text="Normal"
|
isCritical: false,
|
||||||
/>
|
isHighlight: false
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryBadge = (category: string) => {
|
|
||||||
const categoryConfig = {
|
|
||||||
'Harinas': { color: 'default' },
|
|
||||||
'Levaduras': { color: 'info' },
|
|
||||||
'Lácteos': { color: 'secondary' },
|
|
||||||
'Grasas': { color: 'warning' },
|
|
||||||
'Azúcares': { color: 'primary' },
|
|
||||||
'Especias': { color: 'success' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = categoryConfig[category as keyof typeof categoryConfig] || { color: 'default' };
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={config.color as any}
|
|
||||||
text={category}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredItems = mockInventoryItems.filter(item => {
|
const filteredItems = mockInventoryItems.filter(item => {
|
||||||
@@ -258,132 +240,60 @@ const InventoryPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Inventory Items Grid */}
|
{/* Inventory Items Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredItems.map((item) => (
|
{filteredItems.map((item) => {
|
||||||
<Card key={item.id} className="p-4">
|
const statusConfig = getInventoryStatusConfig(item);
|
||||||
<div className="space-y-4">
|
const stockPercentage = Math.round((item.currentStock / item.maxStock) * 100);
|
||||||
{/* Header */}
|
const isExpiringSoon = new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
<div className="flex items-start justify-between">
|
const isExpired = new Date(item.expirationDate) < new Date();
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
|
||||||
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-[var(--text-primary)]">
|
|
||||||
{item.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
{item.supplier}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getStockStatusBadge(item)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category and Stock */}
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<StatusCard
|
||||||
<div>
|
key={item.id}
|
||||||
{getCategoryBadge(item.category)}
|
id={item.id}
|
||||||
</div>
|
statusIndicator={statusConfig}
|
||||||
<div className="text-right">
|
title={item.name}
|
||||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
subtitle={`${item.category} • ${item.supplier}`}
|
||||||
{item.currentStock} {item.unit}
|
primaryValue={item.currentStock}
|
||||||
</div>
|
primaryValueLabel={item.unit}
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
secondaryInfo={{
|
||||||
Mín: {item.minStock} | Máx: {item.maxStock}
|
label: 'Valor total',
|
||||||
</div>
|
value: `${formatters.currency(item.currentStock * item.cost)} (${formatters.currency(item.cost)}/${item.unit})`
|
||||||
</div>
|
}}
|
||||||
</div>
|
progress={{
|
||||||
|
label: 'Nivel de stock',
|
||||||
{/* Value and Dates */}
|
percentage: stockPercentage,
|
||||||
<div className="flex items-center justify-between text-sm">
|
color: statusConfig.color
|
||||||
<div>
|
}}
|
||||||
<div className="text-[var(--text-secondary)]">Costo unitario:</div>
|
metadata={[
|
||||||
<div className="font-medium text-[var(--text-primary)]">
|
`Rango: ${item.minStock} - ${item.maxStock} ${item.unit}`,
|
||||||
{formatters.currency(item.cost)}
|
`Caduca: ${new Date(item.expirationDate).toLocaleDateString('es-ES')}${isExpired ? ' (CADUCADO)' : isExpiringSoon ? ' (PRONTO)' : ''}`,
|
||||||
</div>
|
`Último restock: ${new Date(item.lastRestocked).toLocaleDateString('es-ES')}`
|
||||||
</div>
|
]}
|
||||||
<div className="text-right">
|
actions={[
|
||||||
<div className="text-[var(--text-secondary)]">Valor total:</div>
|
{
|
||||||
<div className="font-medium text-[var(--text-primary)]">
|
label: 'Ver',
|
||||||
{formatters.currency(item.currentStock * item.cost)}
|
icon: Eye,
|
||||||
</div>
|
variant: 'outline',
|
||||||
</div>
|
onClick: () => {
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[var(--text-secondary)]">Último restock:</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{new Date(item.lastRestocked).toLocaleDateString('es-ES')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[var(--text-secondary)]">Caducidad:</span>
|
|
||||||
<span className={`font-medium ${
|
|
||||||
new Date(item.expirationDate) < new Date()
|
|
||||||
? 'text-[var(--color-error)]'
|
|
||||||
: new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
||||||
? 'text-[var(--color-warning)]'
|
|
||||||
: 'text-[var(--text-primary)]'
|
|
||||||
}`}>
|
|
||||||
{new Date(item.expirationDate).toLocaleDateString('es-ES')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stock Level Progress */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-[var(--text-secondary)]">Nivel de stock</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{Math.round((item.currentStock / item.maxStock) * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
|
||||||
item.currentStock <= item.minStock
|
|
||||||
? 'bg-[var(--color-error)]'
|
|
||||||
: item.currentStock <= item.minStock * 1.5
|
|
||||||
? 'bg-[var(--color-warning)]'
|
|
||||||
: 'bg-[var(--color-success)]'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min((item.currentStock / item.maxStock) * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
|
setModalMode('view');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
},
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
{
|
||||||
Ver
|
label: 'Editar',
|
||||||
</Button>
|
icon: Edit,
|
||||||
<Button
|
variant: 'outline',
|
||||||
variant="outline"
|
onClick: () => {
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
|
setModalMode('edit');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
}
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
]}
|
||||||
Editar
|
/>
|
||||||
</Button>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
@@ -403,19 +313,107 @@ const InventoryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inventory Form Modal */}
|
{/* Inventory Item Modal */}
|
||||||
{showForm && (
|
{showForm && selectedItem && (
|
||||||
<InventoryForm
|
<StatusModal
|
||||||
item={selectedItem}
|
isOpen={showForm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
|
setModalMode('view');
|
||||||
}}
|
}}
|
||||||
onSave={(item) => {
|
mode={modalMode}
|
||||||
// Handle save logic
|
onModeChange={setModalMode}
|
||||||
console.log('Saving item:', item);
|
title={selectedItem.name}
|
||||||
setShowForm(false);
|
subtitle={`${selectedItem.category} - ${selectedItem.supplier}`}
|
||||||
setSelectedItem(null);
|
statusIndicator={getInventoryStatusConfig(selectedItem)}
|
||||||
|
size="lg"
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
title: 'Información Básica',
|
||||||
|
icon: Package,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Nombre',
|
||||||
|
value: selectedItem.name,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Categoría',
|
||||||
|
value: selectedItem.category
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Proveedor',
|
||||||
|
value: selectedItem.supplier
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unidad de medida',
|
||||||
|
value: selectedItem.unit
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stock y Niveles',
|
||||||
|
icon: Package,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Stock actual',
|
||||||
|
value: `${selectedItem.currentStock} ${selectedItem.unit}`,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stock mínimo',
|
||||||
|
value: `${selectedItem.minStock} ${selectedItem.unit}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stock máximo',
|
||||||
|
value: `${selectedItem.maxStock} ${selectedItem.unit}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Porcentaje de stock',
|
||||||
|
value: Math.round((selectedItem.currentStock / selectedItem.maxStock) * 100),
|
||||||
|
type: 'percentage',
|
||||||
|
highlight: selectedItem.currentStock <= selectedItem.minStock
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Información Financiera',
|
||||||
|
icon: DollarSign,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Costo por unidad',
|
||||||
|
value: selectedItem.cost,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Valor total en stock',
|
||||||
|
value: selectedItem.currentStock * selectedItem.cost,
|
||||||
|
type: 'currency',
|
||||||
|
highlight: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fechas Importantes',
|
||||||
|
icon: Calendar,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Último restock',
|
||||||
|
value: selectedItem.lastRestocked,
|
||||||
|
type: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fecha de caducidad',
|
||||||
|
value: selectedItem.expirationDate,
|
||||||
|
type: 'date',
|
||||||
|
highlight: new Date(selectedItem.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
console.log('Editing inventory item:', selectedItem.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Download, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer } from 'lucide-react';
|
import { Plus, Download, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { OrderForm } from '../../../../components/domain/sales';
|
import { OrderForm } from '../../../../components/domain/sales';
|
||||||
@@ -9,6 +9,7 @@ const OrdersPage: React.FC = () => {
|
|||||||
const [activeTab] = useState('all');
|
const [activeTab] = useState('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
const [selectedOrder, setSelectedOrder] = useState<typeof mockOrders[0] | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<typeof mockOrders[0] | null>(null);
|
||||||
|
|
||||||
const mockOrders = [
|
const mockOrders = [
|
||||||
@@ -80,24 +81,26 @@ const OrdersPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getOrderStatusConfig = (status: string, paymentStatus: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
pending: { text: 'Pendiente', icon: Clock },
|
||||||
in_progress: { color: 'info', text: 'En Proceso', icon: Timer },
|
in_progress: { text: 'En Proceso', icon: Timer },
|
||||||
ready: { color: 'success', text: 'Listo', icon: CheckCircle },
|
ready: { text: 'Listo', icon: CheckCircle },
|
||||||
completed: { color: 'success', text: 'Completado', icon: CheckCircle },
|
completed: { text: 'Completado', icon: CheckCircle },
|
||||||
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
cancelled: { text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
const Icon = config?.icon;
|
const Icon = config?.icon;
|
||||||
return (
|
const isPaymentPending = paymentStatus === 'pending';
|
||||||
<Badge
|
|
||||||
variant={config?.color as any}
|
return {
|
||||||
icon={Icon && <Icon size={12} />}
|
color: getStatusColor(status),
|
||||||
text={config?.text || status}
|
text: config?.text || status,
|
||||||
/>
|
icon: Icon,
|
||||||
);
|
isCritical: false,
|
||||||
|
isHighlight: isPaymentPending
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrders = mockOrders.filter(order => {
|
const filteredOrders = mockOrders.filter(order => {
|
||||||
@@ -211,77 +214,53 @@ const OrdersPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Orders Grid */}
|
{/* Orders Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredOrders.map((order) => (
|
{filteredOrders.map((order) => {
|
||||||
<Card key={order.id} className="p-4">
|
const statusConfig = getOrderStatusConfig(order.status, order.paymentStatus);
|
||||||
<div className="space-y-4">
|
const paymentNote = order.paymentStatus === 'pending' ? 'Pago pendiente' : '';
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
|
||||||
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-mono text-sm font-semibold text-[var(--color-primary)]">
|
|
||||||
{order.id}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">
|
|
||||||
{order.customerName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getStatusBadge(order.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Info */}
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<StatusCard
|
||||||
<div className="text-right">
|
key={order.id}
|
||||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
id={order.id}
|
||||||
{formatters.currency(order.total)}
|
statusIndicator={statusConfig}
|
||||||
</div>
|
title={order.customerName}
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
subtitle={order.id}
|
||||||
{order.items?.length} artículos
|
primaryValue={formatters.currency(order.total)}
|
||||||
</div>
|
primaryValueLabel={`${order.items?.length} artículos`}
|
||||||
</div>
|
secondaryInfo={{
|
||||||
<div className="text-right">
|
label: 'Entrega',
|
||||||
<div className="text-sm text-[var(--text-primary)]">
|
value: `${new Date(order.deliveryDate).toLocaleDateString('es-ES')} - ${new Date(order.deliveryDate).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
}}
|
||||||
</div>
|
metadata={[
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
order.customerEmail,
|
||||||
Entrega: {new Date(order.deliveryDate).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
order.customerPhone,
|
||||||
</div>
|
...(paymentNote ? [paymentNote] : [])
|
||||||
</div>
|
]}
|
||||||
</div>
|
actions={[
|
||||||
|
{
|
||||||
{/* Actions */}
|
label: 'Ver',
|
||||||
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
icon: Eye,
|
||||||
<Button
|
variant: 'outline',
|
||||||
variant="outline"
|
onClick: () => {
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
|
setModalMode('view');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
},
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
{
|
||||||
Ver
|
label: 'Editar',
|
||||||
</Button>
|
icon: Edit,
|
||||||
<Button
|
variant: 'outline',
|
||||||
variant="outline"
|
onClick: () => {
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
|
setModalMode('edit');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
}
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
]}
|
||||||
Editar
|
/>
|
||||||
</Button>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
@@ -301,20 +280,117 @@ const OrdersPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Order Form Modal */}
|
{/* Order Details Modal */}
|
||||||
{showForm && (
|
{showForm && selectedOrder && (
|
||||||
<OrderForm
|
<StatusModal
|
||||||
orderId={selectedOrder?.id}
|
isOpen={showForm}
|
||||||
onOrderCancel={() => {
|
onClose={() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
|
setModalMode('view');
|
||||||
}}
|
}}
|
||||||
onOrderSave={async (order: any) => {
|
mode={modalMode}
|
||||||
// Handle save logic
|
onModeChange={setModalMode}
|
||||||
console.log('Saving order:', order);
|
title={selectedOrder.customerName}
|
||||||
setShowForm(false);
|
subtitle={`Pedido ${selectedOrder.id}`}
|
||||||
setSelectedOrder(null);
|
statusIndicator={getOrderStatusConfig(selectedOrder.status, selectedOrder.paymentStatus)}
|
||||||
return true;
|
size="lg"
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
title: 'Información del Cliente',
|
||||||
|
icon: Users,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Nombre',
|
||||||
|
value: selectedOrder.customerName,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Email',
|
||||||
|
value: selectedOrder.customerEmail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Teléfono',
|
||||||
|
value: selectedOrder.customerPhone
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Método de entrega',
|
||||||
|
value: selectedOrder.deliveryMethod === 'pickup' ? 'Recogida' : 'Entrega a domicilio'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Detalles del Pedido',
|
||||||
|
icon: Package,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Fecha del pedido',
|
||||||
|
value: selectedOrder.orderDate,
|
||||||
|
type: 'datetime'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fecha de entrega',
|
||||||
|
value: selectedOrder.deliveryDate,
|
||||||
|
type: 'datetime',
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Artículos',
|
||||||
|
value: selectedOrder.items?.map(item => `${item.name} (${item.quantity})`),
|
||||||
|
type: 'list',
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Información Financiera',
|
||||||
|
icon: DollarSign,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Subtotal',
|
||||||
|
value: selectedOrder.subtotal,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Impuestos',
|
||||||
|
value: selectedOrder.tax,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Descuento',
|
||||||
|
value: selectedOrder.discount,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total',
|
||||||
|
value: selectedOrder.total,
|
||||||
|
type: 'currency',
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Método de pago',
|
||||||
|
value: selectedOrder.paymentMethod === 'card' ? 'Tarjeta' : selectedOrder.paymentMethod === 'cash' ? 'Efectivo' : 'Transferencia'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Estado del pago',
|
||||||
|
value: selectedOrder.paymentStatus === 'paid' ? 'Pagado' : 'Pendiente',
|
||||||
|
type: 'status'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...(selectedOrder.notes ? [{
|
||||||
|
title: 'Notas',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Observaciones',
|
||||||
|
value: selectedOrder.notes,
|
||||||
|
span: 2 as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}] : [])
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
console.log('Editing order:', selectedOrder.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Edit } from 'lucide-react';
|
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Edit } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
|
||||||
const ProcurementPage: React.FC = () => {
|
const ProcurementPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState('orders');
|
const [activeTab, setActiveTab] = useState('orders');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<typeof mockPurchaseOrders[0] | null>(null);
|
||||||
|
|
||||||
const mockPurchaseOrders = [
|
const mockPurchaseOrders = [
|
||||||
{
|
{
|
||||||
@@ -101,42 +104,27 @@ const ProcurementPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getPurchaseStatusConfig = (status: string, paymentStatus: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
pending: { text: 'Pendiente', icon: Clock },
|
||||||
approved: { color: 'info', text: 'Aprobado', icon: CheckCircle },
|
approved: { text: 'Aprobado', icon: CheckCircle },
|
||||||
in_transit: { color: 'secondary', text: 'En Tránsito', icon: Truck },
|
in_transit: { text: 'En Tránsito', icon: Truck },
|
||||||
delivered: { color: 'success', text: 'Entregado', icon: CheckCircle },
|
delivered: { text: 'Entregado', icon: CheckCircle },
|
||||||
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
cancelled: { text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
const Icon = config?.icon;
|
const Icon = config?.icon;
|
||||||
return (
|
const isPaymentPending = paymentStatus === 'pending';
|
||||||
<Badge
|
const isOverdue = paymentStatus === 'overdue';
|
||||||
variant={config?.color as any}
|
|
||||||
icon={Icon && <Icon size={12} />}
|
|
||||||
text={config?.text || status}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusBadge = (status: string) => {
|
return {
|
||||||
const statusConfig = {
|
color: getStatusColor(status === 'in_transit' ? 'inTransit' : status),
|
||||||
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
text: config?.text || status,
|
||||||
paid: { color: 'success', text: 'Pagado', icon: CheckCircle },
|
icon: Icon,
|
||||||
overdue: { color: 'error', text: 'Vencido', icon: AlertCircle },
|
isCritical: isOverdue,
|
||||||
|
isHighlight: isPaymentPending
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
|
||||||
const Icon = config?.icon;
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={config?.color as any}
|
|
||||||
icon={Icon && <Icon size={12} />}
|
|
||||||
text={config?.text || status}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrders = mockPurchaseOrders.filter(order => {
|
const filteredOrders = mockPurchaseOrders.filter(order => {
|
||||||
@@ -282,86 +270,52 @@ const ProcurementPage: React.FC = () => {
|
|||||||
{/* Purchase Orders Grid */}
|
{/* Purchase Orders Grid */}
|
||||||
{activeTab === 'orders' && (
|
{activeTab === 'orders' && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredOrders.map((order) => (
|
{filteredOrders.map((order) => {
|
||||||
<Card key={order.id} className="p-4">
|
const statusConfig = getPurchaseStatusConfig(order.status, order.paymentStatus);
|
||||||
<div className="space-y-4">
|
const paymentNote = order.paymentStatus === 'pending' ? 'Pago pendiente' : order.paymentStatus === 'overdue' ? 'Pago vencido' : '';
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
|
||||||
<ShoppingCart className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-mono text-sm font-semibold text-[var(--color-primary)]">
|
|
||||||
{order.id}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">
|
|
||||||
{order.supplier}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getStatusBadge(order.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Info */}
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<StatusCard
|
||||||
<div className="text-right">
|
key={order.id}
|
||||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
id={order.id}
|
||||||
{formatters.currency(order.totalAmount)}
|
statusIndicator={statusConfig}
|
||||||
</div>
|
title={order.supplier}
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
subtitle={order.id}
|
||||||
{order.items?.length} artículos
|
primaryValue={formatters.currency(order.totalAmount)}
|
||||||
</div>
|
primaryValueLabel={`${order.items?.length} artículos`}
|
||||||
</div>
|
secondaryInfo={{
|
||||||
<div className="text-right">
|
label: 'Entrega',
|
||||||
<div className="text-sm text-[var(--text-primary)]">
|
value: `${new Date(order.deliveryDate).toLocaleDateString('es-ES')} (pedido: ${new Date(order.orderDate).toLocaleDateString('es-ES')})`
|
||||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
}}
|
||||||
</div>
|
metadata={[
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
...(order.notes ? [`"${order.notes}"`] : []),
|
||||||
Entrega prevista
|
...(paymentNote ? [paymentNote] : [])
|
||||||
</div>
|
]}
|
||||||
</div>
|
actions={[
|
||||||
</div>
|
{
|
||||||
|
label: 'Ver',
|
||||||
{/* Payment Status */}
|
icon: Eye,
|
||||||
<div className="flex items-center justify-between">
|
variant: 'outline',
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
onClick: () => {
|
||||||
Estado del pago:
|
setSelectedOrder(order);
|
||||||
</div>
|
setModalMode('view');
|
||||||
{getPaymentStatusBadge(order.paymentStatus)}
|
setShowForm(true);
|
||||||
</div>
|
}
|
||||||
|
},
|
||||||
{/* Notes */}
|
{
|
||||||
{order.notes && (
|
label: 'Editar',
|
||||||
<div className="bg-[var(--bg-secondary)] p-2 rounded text-xs text-[var(--text-secondary)] italic">
|
icon: Edit,
|
||||||
"{order.notes}"
|
variant: 'outline',
|
||||||
</div>
|
onClick: () => {
|
||||||
)}
|
setSelectedOrder(order);
|
||||||
|
setModalMode('edit');
|
||||||
{/* Actions */}
|
setShowForm(true);
|
||||||
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
}
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
]}
|
||||||
size="sm"
|
/>
|
||||||
className="flex-1"
|
);
|
||||||
onClick={() => console.log('View order', order.id)}
|
})}
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
|
||||||
Ver
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => console.log('Edit order', order.id)}
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -499,6 +453,115 @@ const ProcurementPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Purchase Order Modal */}
|
||||||
|
{showForm && selectedOrder && (
|
||||||
|
<StatusModal
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedOrder(null);
|
||||||
|
setModalMode('view');
|
||||||
|
}}
|
||||||
|
mode={modalMode}
|
||||||
|
onModeChange={setModalMode}
|
||||||
|
title={selectedOrder.supplier}
|
||||||
|
subtitle={`Orden de Compra ${selectedOrder.id}`}
|
||||||
|
statusIndicator={getPurchaseStatusConfig(selectedOrder.status, selectedOrder.paymentStatus)}
|
||||||
|
size="lg"
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
title: 'Información del Proveedor',
|
||||||
|
icon: Package,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Proveedor',
|
||||||
|
value: selectedOrder.supplier,
|
||||||
|
highlight: true,
|
||||||
|
editable: true,
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Nombre del proveedor'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ID de Orden',
|
||||||
|
value: selectedOrder.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Estado de Pago',
|
||||||
|
value: selectedOrder.paymentStatus === 'paid' ? 'Pagado' : selectedOrder.paymentStatus === 'pending' ? 'Pendiente' : 'Vencido',
|
||||||
|
type: 'status'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fechas Importantes',
|
||||||
|
icon: Calendar,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Fecha de pedido',
|
||||||
|
value: selectedOrder.orderDate,
|
||||||
|
type: 'date',
|
||||||
|
editable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fecha de entrega',
|
||||||
|
value: selectedOrder.deliveryDate,
|
||||||
|
type: 'date',
|
||||||
|
highlight: true,
|
||||||
|
editable: true,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Información Financiera',
|
||||||
|
icon: DollarSign,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Importe total',
|
||||||
|
value: selectedOrder.totalAmount,
|
||||||
|
type: 'currency',
|
||||||
|
highlight: true,
|
||||||
|
editable: true,
|
||||||
|
required: true,
|
||||||
|
placeholder: '0.00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Número de artículos',
|
||||||
|
value: `${selectedOrder.items?.length} productos`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Artículos Pedidos',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Lista de productos',
|
||||||
|
value: selectedOrder.items?.map(item => `${item.name}: ${item.quantity} ${item.unit} - ${formatters.currency(item.total)}`),
|
||||||
|
type: 'list',
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...(selectedOrder.notes ? [{
|
||||||
|
title: 'Notas Adicionales',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Observaciones',
|
||||||
|
value: selectedOrder.notes,
|
||||||
|
span: 2 as const,
|
||||||
|
editable: true,
|
||||||
|
placeholder: 'Añadir notas sobre la orden de compra...'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}] : [])
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
console.log('Editing purchase order:', selectedOrder.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Download, Clock, Users, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Calendar, Zap } from 'lucide-react';
|
import { Plus, Download, Clock, Users, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Calendar, Zap, Package } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { pagePresets } from '../../../../components/ui/Stats/StatsPresets';
|
import { pagePresets } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||||
@@ -10,6 +10,7 @@ const ProductionPage: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedOrder, setSelectedOrder] = useState<typeof mockProductionOrders[0] | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<typeof mockProductionOrders[0] | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
|
|
||||||
const mockProductionStats = {
|
const mockProductionStats = {
|
||||||
dailyTarget: 150,
|
dailyTarget: 150,
|
||||||
@@ -111,42 +112,25 @@ const ProductionPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getProductionStatusConfig = (status: string, priority: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
pending: { text: 'Pendiente', icon: Clock },
|
||||||
in_progress: { color: 'info', text: 'En Proceso', icon: Timer },
|
in_progress: { text: 'En Proceso', icon: Timer },
|
||||||
completed: { color: 'success', text: 'Completado', icon: CheckCircle },
|
completed: { text: 'Completado', icon: CheckCircle },
|
||||||
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
cancelled: { text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
const Icon = config?.icon;
|
const Icon = config?.icon;
|
||||||
return (
|
const isUrgent = priority === 'urgent';
|
||||||
<Badge
|
|
||||||
variant={config?.color as any}
|
|
||||||
icon={Icon && <Icon size={12} />}
|
|
||||||
text={config?.text || status}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityBadge = (priority: string) => {
|
return {
|
||||||
const priorityConfig = {
|
color: getStatusColor(status),
|
||||||
low: { color: 'outline', text: 'Baja' },
|
text: config?.text || status,
|
||||||
medium: { color: 'secondary', text: 'Media' },
|
icon: Icon,
|
||||||
high: { color: 'warning', text: 'Alta' },
|
isCritical: isUrgent,
|
||||||
urgent: { color: 'error', text: 'Urgente', icon: Zap },
|
isHighlight: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
|
||||||
const Icon = config?.icon;
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={config?.color as any}
|
|
||||||
icon={Icon && <Icon size={12} />}
|
|
||||||
text={config?.text || priority}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrders = mockProductionOrders.filter(order => {
|
const filteredOrders = mockProductionOrders.filter(order => {
|
||||||
@@ -245,120 +229,52 @@ const ProductionPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Production Orders Grid */}
|
{/* Production Orders Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredOrders.map((order) => (
|
{filteredOrders.map((order) => {
|
||||||
<Card key={order.id} className="p-4">
|
const statusConfig = getProductionStatusConfig(order.status, order.priority);
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
|
||||||
<ChefHat className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-[var(--text-primary)]">
|
|
||||||
{order.recipeName}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
ID: {order.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getStatusBadge(order.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priority and Quantity */}
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<StatusCard
|
||||||
<div>
|
key={order.id}
|
||||||
{getPriorityBadge(order.priority)}
|
id={order.id}
|
||||||
</div>
|
statusIndicator={statusConfig}
|
||||||
<div className="text-right">
|
title={order.recipeName}
|
||||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
subtitle={`Asignado a: ${order.assignedTo}`}
|
||||||
{order.quantity}
|
primaryValue={order.quantity}
|
||||||
</div>
|
primaryValueLabel="unidades"
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
secondaryInfo={{
|
||||||
unidades
|
label: 'Horario',
|
||||||
</div>
|
value: `${new Date(order.startTime).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })} → ${new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
</div>
|
}}
|
||||||
</div>
|
progress={{
|
||||||
|
label: 'Progreso',
|
||||||
{/* Assigned Worker */}
|
percentage: order.progress,
|
||||||
<div className="flex items-center gap-2">
|
color: statusConfig.color
|
||||||
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
|
}}
|
||||||
<span className="text-sm text-[var(--text-primary)]">
|
actions={[
|
||||||
{order.assignedTo}
|
{
|
||||||
</span>
|
label: 'Ver',
|
||||||
</div>
|
icon: Eye,
|
||||||
|
variant: 'outline',
|
||||||
{/* Time Information */}
|
onClick: () => {
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[var(--text-secondary)]">Inicio:</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{new Date(order.startTime).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[var(--text-secondary)]">Est. finalización:</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-[var(--text-secondary)]">Progreso</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{order.progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
|
||||||
order.progress === 100
|
|
||||||
? 'bg-[var(--color-success)]'
|
|
||||||
: order.progress > 50
|
|
||||||
? 'bg-[var(--color-info)]'
|
|
||||||
: order.progress > 0
|
|
||||||
? 'bg-[var(--color-warning)]'
|
|
||||||
: 'bg-[var(--bg-quaternary)]'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${order.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
|
setModalMode('view');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
},
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
{
|
||||||
Ver
|
label: 'Editar',
|
||||||
</Button>
|
icon: Edit,
|
||||||
<Button
|
variant: 'outline',
|
||||||
variant="outline"
|
onClick: () => {
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
|
setModalMode('edit');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
}
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
]}
|
||||||
Editar
|
/>
|
||||||
</Button>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
@@ -388,52 +304,70 @@ const ProductionPage: React.FC = () => {
|
|||||||
<QualityControl />
|
<QualityControl />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Production Order Form Modal - Placeholder */}
|
{/* Production Order Modal */}
|
||||||
{showForm && (
|
{showForm && selectedOrder && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<StatusModal
|
||||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-auto m-4 p-6">
|
isOpen={showForm}
|
||||||
<div className="flex items-center justify-between mb-4">
|
onClose={() => {
|
||||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
setShowForm(false);
|
||||||
{selectedOrder ? 'Ver Orden de Producción' : 'Nueva Orden de Producción'}
|
setSelectedOrder(null);
|
||||||
</h2>
|
setModalMode('view');
|
||||||
<Button
|
}}
|
||||||
variant="outline"
|
mode={modalMode}
|
||||||
size="sm"
|
onModeChange={setModalMode}
|
||||||
onClick={() => {
|
title={selectedOrder.recipeName}
|
||||||
setShowForm(false);
|
subtitle={`Orden de Producción #${selectedOrder.id}`}
|
||||||
setSelectedOrder(null);
|
statusIndicator={getProductionStatusConfig(selectedOrder.status, selectedOrder.priority)}
|
||||||
}}
|
size="lg"
|
||||||
>
|
sections={[
|
||||||
Cerrar
|
{
|
||||||
</Button>
|
title: 'Información General',
|
||||||
</div>
|
icon: Package,
|
||||||
{selectedOrder && (
|
fields: [
|
||||||
<div className="space-y-4">
|
{
|
||||||
<h3 className="text-lg font-medium">{selectedOrder.recipeName}</h3>
|
label: 'Cantidad',
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
value: `${selectedOrder.quantity} unidades`,
|
||||||
<div>
|
highlight: true
|
||||||
<span className="font-medium">Cantidad:</span> {selectedOrder.quantity} unidades
|
},
|
||||||
</div>
|
{
|
||||||
<div>
|
label: 'Asignado a',
|
||||||
<span className="font-medium">Asignado a:</span> {selectedOrder.assignedTo}
|
value: selectedOrder.assignedTo,
|
||||||
</div>
|
},
|
||||||
<div>
|
{
|
||||||
<span className="font-medium">Estado:</span> {selectedOrder.status}
|
label: 'Prioridad',
|
||||||
</div>
|
value: selectedOrder.priority,
|
||||||
<div>
|
type: 'status'
|
||||||
<span className="font-medium">Progreso:</span> {selectedOrder.progress}%
|
},
|
||||||
</div>
|
{
|
||||||
<div>
|
label: 'Progreso',
|
||||||
<span className="font-medium">Inicio:</span> {new Date(selectedOrder.startTime).toLocaleString('es-ES')}
|
value: selectedOrder.progress,
|
||||||
</div>
|
type: 'percentage',
|
||||||
<div>
|
highlight: true
|
||||||
<span className="font-medium">Finalización:</span> {new Date(selectedOrder.estimatedCompletion).toLocaleString('es-ES')}
|
}
|
||||||
</div>
|
]
|
||||||
</div>
|
},
|
||||||
</div>
|
{
|
||||||
)}
|
title: 'Cronograma',
|
||||||
</Card>
|
icon: Clock,
|
||||||
</div>
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Hora de inicio',
|
||||||
|
value: selectedOrder.startTime,
|
||||||
|
type: 'datetime'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Finalización estimada',
|
||||||
|
value: selectedOrder.estimatedCompletion,
|
||||||
|
type: 'datetime'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
// Handle edit mode
|
||||||
|
console.log('Editing production order:', selectedOrder.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Download, Star, Clock, Users, DollarSign, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
|
import { Plus, Download, Star, Clock, Users, DollarSign, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
|
||||||
const RecipesPage: React.FC = () => {
|
const RecipesPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState<typeof mockRecipes[0] | null>(null);
|
const [selectedRecipe, setSelectedRecipe] = useState<typeof mockRecipes[0] | null>(null);
|
||||||
|
|
||||||
const mockRecipes = [
|
const mockRecipes = [
|
||||||
@@ -100,38 +101,34 @@ const RecipesPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getCategoryBadge = (category: string) => {
|
const getRecipeStatusConfig = (category: string, difficulty: string, rating: number) => {
|
||||||
const categoryConfig = {
|
const categoryConfig = {
|
||||||
bread: { color: 'default', text: 'Pan' },
|
bread: { text: 'Pan', icon: ChefHat },
|
||||||
pastry: { color: 'warning', text: 'Bollería' },
|
pastry: { text: 'Bollería', icon: ChefHat },
|
||||||
cake: { color: 'secondary', text: 'Tarta' },
|
cake: { text: 'Tarta', icon: ChefHat },
|
||||||
cookie: { color: 'info', text: 'Galleta' },
|
cookie: { text: 'Galleta', icon: ChefHat },
|
||||||
other: { color: 'outline', text: 'Otro' },
|
other: { text: 'Otro', icon: ChefHat },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={config.color as any}
|
|
||||||
text={config.text}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDifficultyBadge = (difficulty: string) => {
|
|
||||||
const difficultyConfig = {
|
const difficultyConfig = {
|
||||||
easy: { color: 'success', text: 'Fácil' },
|
easy: { icon: '●', label: 'Fácil' },
|
||||||
medium: { color: 'warning', text: 'Medio' },
|
medium: { icon: '●●', label: 'Medio' },
|
||||||
hard: { color: 'error', text: 'Difícil' },
|
hard: { icon: '●●●', label: 'Difícil' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
const categoryInfo = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||||
return (
|
const difficultyInfo = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||||
<Badge
|
const isPopular = rating >= 4.7;
|
||||||
variant={config?.color as any}
|
|
||||||
text={config?.text || difficulty}
|
return {
|
||||||
/>
|
color: getStatusColor(category),
|
||||||
);
|
text: categoryInfo.text,
|
||||||
|
icon: categoryInfo.icon,
|
||||||
|
difficultyIcon: difficultyInfo?.icon || '●',
|
||||||
|
difficultyLabel: difficultyInfo?.label || difficulty,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: isPopular
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (minutes: number) => {
|
const formatTime = (minutes: number) => {
|
||||||
@@ -245,130 +242,65 @@ const RecipesPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Recipes Grid */}
|
{/* Recipes Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredRecipes.map((recipe) => (
|
{filteredRecipes.map((recipe) => {
|
||||||
<Card key={recipe.id} className="p-4">
|
const statusConfig = getRecipeStatusConfig(recipe.category, recipe.difficulty, recipe.rating);
|
||||||
<div className="space-y-4">
|
const profitMargin = Math.round((recipe.profit / recipe.price) * 100);
|
||||||
{/* Header with Image */}
|
const totalTime = formatTime(recipe.prepTime + recipe.bakingTime);
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={recipe.image}
|
|
||||||
alt={recipe.name}
|
|
||||||
className="w-16 h-16 rounded-lg object-cover bg-[var(--bg-secondary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium text-[var(--text-primary)] truncate">
|
|
||||||
{recipe.name}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<Star className="w-3 h-3 text-yellow-400 fill-current" />
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">{recipe.rating}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-1 line-clamp-2">
|
|
||||||
{recipe.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges */}
|
return (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<StatusCard
|
||||||
{getCategoryBadge(recipe.category)}
|
key={recipe.id}
|
||||||
{getDifficultyBadge(recipe.difficulty)}
|
id={recipe.id}
|
||||||
</div>
|
statusIndicator={statusConfig}
|
||||||
|
title={recipe.name}
|
||||||
{/* Time and Yield */}
|
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
|
||||||
<div className="flex items-center justify-between text-sm">
|
primaryValue={formatters.currency(recipe.profit)}
|
||||||
<div className="flex items-center gap-1">
|
primaryValueLabel="margen"
|
||||||
<Clock className="w-4 h-4 text-[var(--text-tertiary)]" />
|
secondaryInfo={{
|
||||||
<span className="text-[var(--text-primary)]">
|
label: 'Precio de venta',
|
||||||
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
value: `${formatters.currency(recipe.price)} (costo: ${formatters.currency(recipe.cost)})`
|
||||||
</span>
|
}}
|
||||||
</div>
|
progress={{
|
||||||
<div className="flex items-center gap-1">
|
label: 'Margen de beneficio',
|
||||||
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
|
percentage: profitMargin,
|
||||||
<span className="text-[var(--text-primary)]">
|
color: profitMargin > 50 ? '#10b981' : profitMargin > 30 ? '#f59e0b' : '#ef4444'
|
||||||
{recipe.yield} porciones
|
}}
|
||||||
</span>
|
metadata={[
|
||||||
</div>
|
`Tiempo: ${totalTime}`,
|
||||||
</div>
|
`Porciones: ${recipe.yield}`,
|
||||||
|
`${recipe.ingredients.length} ingredientes principales`
|
||||||
{/* Financial Info */}
|
]}
|
||||||
<div className="space-y-2">
|
actions={[
|
||||||
<div className="flex items-center justify-between text-sm">
|
{
|
||||||
<span className="text-[var(--text-secondary)]">Costo:</span>
|
label: 'Ver',
|
||||||
<span className="font-medium text-[var(--text-primary)]">
|
icon: Eye,
|
||||||
{formatters.currency(recipe.cost)}
|
variant: 'outline',
|
||||||
</span>
|
onClick: () => {
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Precio:</span>
|
|
||||||
<span className="font-medium text-[var(--color-success)]">
|
|
||||||
{formatters.currency(recipe.price)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Margen:</span>
|
|
||||||
<span className="font-bold text-[var(--color-success)]">
|
|
||||||
{formatters.currency(recipe.profit)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profit Margin Bar */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-[var(--text-secondary)]">Margen de beneficio</span>
|
|
||||||
<span className="text-[var(--text-primary)]">
|
|
||||||
{Math.round((recipe.profit / recipe.price) * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
|
||||||
(recipe.profit / recipe.price) > 0.5
|
|
||||||
? 'bg-[var(--color-success)]'
|
|
||||||
: (recipe.profit / recipe.price) > 0.3
|
|
||||||
? 'bg-[var(--color-warning)]'
|
|
||||||
: 'bg-[var(--color-error)]'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min((recipe.profit / recipe.price) * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ingredients Count */}
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">
|
|
||||||
{recipe.ingredients.length} ingredientes principales
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedRecipe(recipe);
|
setSelectedRecipe(recipe);
|
||||||
|
setModalMode('view');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}
|
||||||
>
|
},
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
{
|
||||||
Ver
|
label: 'Editar',
|
||||||
</Button>
|
icon: Edit,
|
||||||
<Button
|
variant: 'outline',
|
||||||
variant="primary"
|
onClick: () => {
|
||||||
size="sm"
|
setSelectedRecipe(recipe);
|
||||||
className="flex-1"
|
setModalMode('edit');
|
||||||
onClick={() => console.log('Produce recipe', recipe.id)}
|
setShowForm(true);
|
||||||
>
|
}
|
||||||
<ChefHat className="w-4 h-4 mr-1" />
|
},
|
||||||
Producir
|
{
|
||||||
</Button>
|
label: 'Producir',
|
||||||
</div>
|
icon: ChefHat,
|
||||||
</div>
|
variant: 'primary',
|
||||||
</Card>
|
onClick: () => console.log('Produce recipe', recipe.id)
|
||||||
))}
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
@@ -388,57 +320,133 @@ const RecipesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recipe Form Modal - Placeholder */}
|
{/* Recipe Details Modal */}
|
||||||
{showForm && (
|
{showForm && selectedRecipe && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<StatusModal
|
||||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-auto m-4 p-6">
|
isOpen={showForm}
|
||||||
<div className="flex items-center justify-between mb-4">
|
onClose={() => {
|
||||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
setShowForm(false);
|
||||||
{selectedRecipe ? 'Ver Receta' : 'Nueva Receta'}
|
setSelectedRecipe(null);
|
||||||
</h2>
|
setModalMode('view');
|
||||||
<Button
|
}}
|
||||||
variant="outline"
|
mode={modalMode}
|
||||||
size="sm"
|
onModeChange={setModalMode}
|
||||||
onClick={() => {
|
title={selectedRecipe.name}
|
||||||
setShowForm(false);
|
subtitle={selectedRecipe.description}
|
||||||
setSelectedRecipe(null);
|
statusIndicator={getRecipeStatusConfig(selectedRecipe.category, selectedRecipe.difficulty, selectedRecipe.rating)}
|
||||||
}}
|
image={selectedRecipe.image}
|
||||||
>
|
size="xl"
|
||||||
Cerrar
|
sections={[
|
||||||
</Button>
|
{
|
||||||
</div>
|
title: 'Información Básica',
|
||||||
{selectedRecipe && (
|
icon: ChefHat,
|
||||||
<div className="space-y-4">
|
fields: [
|
||||||
<img
|
{
|
||||||
src={selectedRecipe.image}
|
label: 'Categoría',
|
||||||
alt={selectedRecipe.name}
|
value: selectedRecipe.category,
|
||||||
className="w-full h-48 object-cover rounded-lg"
|
type: 'status'
|
||||||
/>
|
},
|
||||||
<h3 className="text-lg font-medium">{selectedRecipe.name}</h3>
|
{
|
||||||
<p className="text-[var(--text-secondary)]">{selectedRecipe.description}</p>
|
label: 'Dificultad',
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
value: selectedRecipe.difficulty
|
||||||
<div>
|
},
|
||||||
<span className="font-medium">Tiempo total:</span> {formatTime(selectedRecipe.prepTime + selectedRecipe.bakingTime)}
|
{
|
||||||
</div>
|
label: 'Valoración',
|
||||||
<div>
|
value: `${selectedRecipe.rating} ★`,
|
||||||
<span className="font-medium">Rendimiento:</span> {selectedRecipe.yield} porciones
|
highlight: selectedRecipe.rating >= 4.7
|
||||||
</div>
|
},
|
||||||
</div>
|
{
|
||||||
<div>
|
label: 'Rendimiento',
|
||||||
<h4 className="font-medium mb-2">Ingredientes:</h4>
|
value: `${selectedRecipe.yield} porciones`
|
||||||
<ul className="space-y-1 text-sm">
|
}
|
||||||
{selectedRecipe.ingredients.map((ing, i) => (
|
]
|
||||||
<li key={i} className="flex justify-between">
|
},
|
||||||
<span>{ing.name}</span>
|
{
|
||||||
<span>{ing.quantity} {ing.unit}</span>
|
title: 'Tiempos',
|
||||||
</li>
|
icon: Clock,
|
||||||
))}
|
fields: [
|
||||||
</ul>
|
{
|
||||||
</div>
|
label: 'Tiempo de preparación',
|
||||||
</div>
|
value: formatTime(selectedRecipe.prepTime)
|
||||||
)}
|
},
|
||||||
</Card>
|
{
|
||||||
</div>
|
label: 'Tiempo de horneado',
|
||||||
|
value: formatTime(selectedRecipe.bakingTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tiempo total',
|
||||||
|
value: formatTime(selectedRecipe.prepTime + selectedRecipe.bakingTime),
|
||||||
|
highlight: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Análisis Financiero',
|
||||||
|
icon: DollarSign,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Costo de producción',
|
||||||
|
value: selectedRecipe.cost,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Precio de venta',
|
||||||
|
value: selectedRecipe.price,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Margen de beneficio',
|
||||||
|
value: selectedRecipe.profit,
|
||||||
|
type: 'currency',
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Porcentaje de margen',
|
||||||
|
value: Math.round((selectedRecipe.profit / selectedRecipe.price) * 100),
|
||||||
|
type: 'percentage',
|
||||||
|
highlight: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ingredientes',
|
||||||
|
icon: Package,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Lista de ingredientes',
|
||||||
|
value: selectedRecipe.ingredients.map(ing => `${ing.name}: ${ing.quantity} ${ing.unit}`),
|
||||||
|
type: 'list',
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Etiquetas',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Tags',
|
||||||
|
value: selectedRecipe.tags.join(', '),
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Producir',
|
||||||
|
icon: ChefHat,
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: () => {
|
||||||
|
console.log('Producing recipe:', selectedRecipe.id);
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
console.log('Editing recipe:', selectedRecipe.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
131
frontend/src/styles/colors.d.ts
vendored
Normal file
131
frontend/src/styles/colors.d.ts
vendored
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
export interface ColorScale {
|
||||||
|
50: string;
|
||||||
|
100: string;
|
||||||
|
200: string;
|
||||||
|
300: string;
|
||||||
|
400: string;
|
||||||
|
500: string;
|
||||||
|
600: string;
|
||||||
|
700: string;
|
||||||
|
800: string;
|
||||||
|
900: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusColorConfig {
|
||||||
|
primary: string;
|
||||||
|
light: string;
|
||||||
|
dark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseColors {
|
||||||
|
primary: ColorScale;
|
||||||
|
secondary: ColorScale;
|
||||||
|
success: ColorScale;
|
||||||
|
warning: ColorScale;
|
||||||
|
error: ColorScale;
|
||||||
|
info: ColorScale;
|
||||||
|
neutral: ColorScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BakeryColors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusColors {
|
||||||
|
pending: StatusColorConfig;
|
||||||
|
inProgress: StatusColorConfig;
|
||||||
|
completed: StatusColorConfig;
|
||||||
|
cancelled: StatusColorConfig;
|
||||||
|
normal: StatusColorConfig;
|
||||||
|
low: StatusColorConfig;
|
||||||
|
out: StatusColorConfig;
|
||||||
|
expired: StatusColorConfig;
|
||||||
|
approved: StatusColorConfig;
|
||||||
|
inTransit: StatusColorConfig;
|
||||||
|
delivered: StatusColorConfig;
|
||||||
|
bread: StatusColorConfig;
|
||||||
|
pastry: StatusColorConfig;
|
||||||
|
cake: StatusColorConfig;
|
||||||
|
cookie: StatusColorConfig;
|
||||||
|
other: StatusColorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartColors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeColors {
|
||||||
|
bg: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
tertiary: string;
|
||||||
|
quaternary: string;
|
||||||
|
overlay: string;
|
||||||
|
modalBackdrop: string;
|
||||||
|
};
|
||||||
|
text: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
tertiary: string;
|
||||||
|
quaternary: string;
|
||||||
|
inverse: string;
|
||||||
|
muted: string;
|
||||||
|
disabled: string;
|
||||||
|
};
|
||||||
|
border: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
tertiary: string;
|
||||||
|
focus: string;
|
||||||
|
error: string;
|
||||||
|
success: string;
|
||||||
|
};
|
||||||
|
surface: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
tertiary: string;
|
||||||
|
raised: string;
|
||||||
|
overlay: string;
|
||||||
|
};
|
||||||
|
input: {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
borderFocus: string;
|
||||||
|
borderError: string;
|
||||||
|
placeholder: string;
|
||||||
|
};
|
||||||
|
nav: {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
itemHover: string;
|
||||||
|
itemActive: string;
|
||||||
|
};
|
||||||
|
card: {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
shadow: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const baseColors: BaseColors;
|
||||||
|
declare const bakeryColors: BakeryColors;
|
||||||
|
declare const statusColors: StatusColors;
|
||||||
|
declare const chartColors: ChartColors;
|
||||||
|
declare const lightTheme: ThemeColors;
|
||||||
|
declare const darkTheme: ThemeColors;
|
||||||
|
declare const tailwindColorConfig: any;
|
||||||
|
declare const generateCSSVariables: (theme?: string) => { [key: string]: string };
|
||||||
|
|
||||||
|
declare const _default: {
|
||||||
|
baseColors: BaseColors;
|
||||||
|
bakeryColors: BakeryColors;
|
||||||
|
statusColors: StatusColors;
|
||||||
|
chartColors: ChartColors;
|
||||||
|
lightTheme: ThemeColors;
|
||||||
|
darkTheme: ThemeColors;
|
||||||
|
tailwindColorConfig: any;
|
||||||
|
generateCSSVariables: (theme?: string) => { [key: string]: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export { baseColors, bakeryColors, statusColors, chartColors, lightTheme, darkTheme, tailwindColorConfig, generateCSSVariables };
|
||||||
|
export default _default;
|
||||||
@@ -123,6 +123,94 @@ export const bakeryColors = {
|
|||||||
cream: '#fffdd0',
|
cream: '#fffdd0',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Status Colors for Cards and Components
|
||||||
|
export const statusColors = {
|
||||||
|
// Order/Production Statuses
|
||||||
|
pending: {
|
||||||
|
primary: '#f59e0b',
|
||||||
|
light: '#fef3c7',
|
||||||
|
dark: '#d97706',
|
||||||
|
},
|
||||||
|
inProgress: {
|
||||||
|
primary: '#3b82f6',
|
||||||
|
light: '#dbeafe',
|
||||||
|
dark: '#2563eb',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
primary: '#10b981',
|
||||||
|
light: '#d1fae5',
|
||||||
|
dark: '#059669',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
light: '#fee2e2',
|
||||||
|
dark: '#dc2626',
|
||||||
|
},
|
||||||
|
// Inventory Statuses
|
||||||
|
normal: {
|
||||||
|
primary: '#10b981',
|
||||||
|
light: '#d1fae5',
|
||||||
|
dark: '#059669',
|
||||||
|
},
|
||||||
|
low: {
|
||||||
|
primary: '#f59e0b',
|
||||||
|
light: '#fef3c7',
|
||||||
|
dark: '#d97706',
|
||||||
|
},
|
||||||
|
out: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
light: '#fee2e2',
|
||||||
|
dark: '#dc2626',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
light: '#fee2e2',
|
||||||
|
dark: '#dc2626',
|
||||||
|
},
|
||||||
|
// Purchase Order Statuses
|
||||||
|
approved: {
|
||||||
|
primary: '#3b82f6',
|
||||||
|
light: '#dbeafe',
|
||||||
|
dark: '#2563eb',
|
||||||
|
},
|
||||||
|
inTransit: {
|
||||||
|
primary: '#8b5cf6',
|
||||||
|
light: '#ede9fe',
|
||||||
|
dark: '#7c3aed',
|
||||||
|
},
|
||||||
|
delivered: {
|
||||||
|
primary: '#10b981',
|
||||||
|
light: '#d1fae5',
|
||||||
|
dark: '#059669',
|
||||||
|
},
|
||||||
|
// Recipe Categories
|
||||||
|
bread: {
|
||||||
|
primary: '#8b5cf6',
|
||||||
|
light: '#ede9fe',
|
||||||
|
dark: '#7c3aed',
|
||||||
|
},
|
||||||
|
pastry: {
|
||||||
|
primary: '#f59e0b',
|
||||||
|
light: '#fef3c7',
|
||||||
|
dark: '#d97706',
|
||||||
|
},
|
||||||
|
cake: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
light: '#fee2e2',
|
||||||
|
dark: '#dc2626',
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
primary: '#3b82f6',
|
||||||
|
light: '#dbeafe',
|
||||||
|
dark: '#2563eb',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
primary: '#6b7280',
|
||||||
|
light: '#f3f4f6',
|
||||||
|
dark: '#4b5563',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Chart Colors (Colorblind-Safe Data Visualization)
|
// Chart Colors (Colorblind-Safe Data Visualization)
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
primary: '#d97706', // Orange
|
primary: '#d97706', // Orange
|
||||||
@@ -313,6 +401,7 @@ export const generateCSSVariables = (theme = 'light') => {
|
|||||||
export default {
|
export default {
|
||||||
baseColors,
|
baseColors,
|
||||||
bakeryColors,
|
bakeryColors,
|
||||||
|
statusColors,
|
||||||
chartColors,
|
chartColors,
|
||||||
lightTheme,
|
lightTheme,
|
||||||
darkTheme,
|
darkTheme,
|
||||||
|
|||||||
Reference in New Issue
Block a user