Add a ne model and card design across pages

This commit is contained in:
Urtzi Alfaro
2025-08-31 10:46:13 +02:00
parent ab21149acf
commit a8b73e22ea
14 changed files with 1865 additions and 820 deletions

View File

@@ -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

View 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;

View File

@@ -0,0 +1,2 @@
export { StatusCard, getStatusColor } from './StatusCard';
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';

View 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;

View File

@@ -0,0 +1,7 @@
export { StatusModal } from './StatusModal';
export type {
StatusModalProps,
StatusModalField,
StatusModalSection,
StatusModalAction
} from './StatusModal';

View File

@@ -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';