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 [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get navigation routes from config and convert to navigation items - memoized
|
||||
@@ -302,11 +303,59 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
};
|
||||
}, [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
|
||||
const renderItem = (item: NavigationItem, level = 0) => {
|
||||
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
||||
const isExpanded = expandedItems.has(item.id);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isHovered = hoveredItem === item.id;
|
||||
const ItemIcon = item.icon;
|
||||
|
||||
const itemContent = (
|
||||
@@ -317,17 +366,24 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
level > 0 && 'pl-6',
|
||||
)}
|
||||
>
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? '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 && (
|
||||
<Dot className={clsx(
|
||||
@@ -374,24 +430,47 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
data-path={item.path}
|
||||
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'
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
data-path={item.path}
|
||||
onMouseEnter={() => {
|
||||
if (isCollapsed && hasChildren && level === 0) {
|
||||
setHoveredItem(item.id);
|
||||
}
|
||||
}}
|
||||
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}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
{itemContent}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -116,38 +116,38 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'max-w-xs',
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
full: 'max-w-full',
|
||||
xs: 'max-w-xs w-full',
|
||||
sm: 'max-w-sm w-full',
|
||||
md: 'max-w-md w-full',
|
||||
lg: 'max-w-lg w-full max-h-[90vh] overflow-y-auto',
|
||||
xl: 'max-w-xl w-full max-h-[90vh] overflow-y-auto',
|
||||
'2xl': 'max-w-2xl w-full max-h-[90vh] overflow-y-auto',
|
||||
full: 'max-w-full w-full max-h-[90vh] overflow-y-auto',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
};
|
||||
@@ -159,7 +159,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
||||
);
|
||||
|
||||
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',
|
||||
variantClasses[variant].content,
|
||||
sizeClasses[size],
|
||||
@@ -192,7 +192,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
||||
{showCloseButton && (
|
||||
<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}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
@@ -225,7 +225,7 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
||||
...props
|
||||
}, ref) => {
|
||||
const classes = clsx(
|
||||
'px-6 py-4 border-b border-border-primary',
|
||||
'px-6 py-4 border-b border-[var(--border-primary)]',
|
||||
className
|
||||
);
|
||||
|
||||
@@ -240,13 +240,13 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
||||
{title && (
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-lg font-semibold text-text-primary"
|
||||
className="text-lg font-semibold text-[var(--text-primary)]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -255,7 +255,7 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
||||
{showCloseButton && onClose && (
|
||||
<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}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
@@ -333,7 +333,7 @@ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({
|
||||
};
|
||||
|
||||
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],
|
||||
justifyClasses[justify],
|
||||
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 { ListItem } from './ListItem';
|
||||
export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -30,4 +32,6 @@ export type { ThemeToggleProps } from './ThemeToggle';
|
||||
export type { ProgressBarProps } from './ProgressBar';
|
||||
export type { StatusIndicatorProps } from './StatusIndicator';
|
||||
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';
|
||||
Reference in New Issue
Block a user