508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
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' | 'select';
|
||
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;
|
||
actionsPosition?: 'header' | 'footer'; // New prop for positioning actions
|
||
onEdit?: () => void;
|
||
onSave?: () => Promise<void>;
|
||
onCancel?: () => void;
|
||
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => 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"
|
||
/>
|
||
);
|
||
case 'select':
|
||
return (
|
||
<select
|
||
value={String(field.value)}
|
||
onChange={(e) => onChange?.(e.target.value)}
|
||
required={field.required}
|
||
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 bg-[var(--bg-primary)]"
|
||
>
|
||
{field.placeholder && (
|
||
<option value="" disabled>
|
||
{field.placeholder}
|
||
</option>
|
||
)}
|
||
{field.options?.map((option, index) => (
|
||
<option key={index} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
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)
|
||
*
|
||
* Features:
|
||
* - Supports actions in header (tab-style navigation) or footer (default)
|
||
* - Tab-style navigation in header improves discoverability for multi-view modals
|
||
* - Maintains backward compatibility - existing modals continue working unchanged
|
||
* - Responsive design with horizontal scroll for many tabs on mobile
|
||
* - Active state indicated by disabled=true for navigation actions
|
||
*/
|
||
export const StatusModal: React.FC<StatusModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
mode,
|
||
onModeChange,
|
||
title,
|
||
subtitle,
|
||
statusIndicator,
|
||
image,
|
||
sections,
|
||
actions = [],
|
||
showDefaultActions = true,
|
||
actionsPosition = 'footer',
|
||
onEdit,
|
||
onSave,
|
||
onCancel,
|
||
onFieldChange,
|
||
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];
|
||
|
||
// Render top navigation actions (tab-like style)
|
||
const renderTopActions = () => {
|
||
if (actionsPosition !== 'header' || allActions.length === 0) return null;
|
||
|
||
return (
|
||
<div className="border-b border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
||
<div className="px-6 py-1">
|
||
<div className="flex gap-0 overflow-x-auto scrollbar-hide" style={{scrollbarWidth: 'none', msOverflowStyle: 'none'}}>
|
||
{allActions.map((action, index) => (
|
||
<button
|
||
key={index}
|
||
onClick={action.disabled || loading ? undefined : action.onClick}
|
||
disabled={loading}
|
||
className={`
|
||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200
|
||
min-w-fit whitespace-nowrap border-b-2 -mb-px
|
||
${loading
|
||
? 'opacity-50 cursor-not-allowed text-[var(--text-tertiary)] border-transparent'
|
||
: action.disabled
|
||
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--color-primary)]/8 cursor-default'
|
||
: action.variant === 'danger'
|
||
? 'text-red-600 border-transparent hover:border-red-300 hover:bg-red-50'
|
||
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]'
|
||
}
|
||
`}
|
||
>
|
||
{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" />}
|
||
<span>{action.label}</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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}
|
||
/>
|
||
|
||
{/* Top Navigation Actions */}
|
||
{renderTopActions()}
|
||
|
||
<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-baseline gap-3 pb-3 border-b border-[var(--border-primary)]">
|
||
{section.icon && (
|
||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||
)}
|
||
<h3 className="font-medium text-[var(--text-primary)] leading-tight">
|
||
{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',
|
||
(value: string | number) => onFieldChange?.(sectionIndex, fieldIndex, value)
|
||
)}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ModalBody>
|
||
|
||
{allActions.length > 0 && actionsPosition === 'footer' && (
|
||
<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; |