Refactor components and modals

This commit is contained in:
Urtzi Alfaro
2025-09-26 07:46:25 +02:00
parent cf4405b771
commit d573c38621
80 changed files with 3421 additions and 4617 deletions

View File

@@ -0,0 +1,698 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { LucideIcon, Edit } from 'lucide-react';
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
import { Button } from '../Button';
import { Input } from '../Input';
import { Select } from '../Select';
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
import { formatters } from '../Stats/StatsPresets';
export interface EditViewModalField {
label: string;
value: string | number | React.ReactNode;
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component';
highlight?: boolean;
span?: 1 | 2 | 3; // For grid layout - added 3 for full width on larger screens
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
validation?: (value: string | number) => string | null; // Custom validation function
helpText?: string; // Help text displayed below the field
component?: React.ComponentType<any>; // For custom components
componentProps?: Record<string, any>; // Props for custom components
}
export interface EditViewModalSection {
title: string;
icon?: LucideIcon;
fields: EditViewModalField[];
collapsible?: boolean; // Whether section can be collapsed
collapsed?: boolean; // Initial collapsed state
description?: string; // Section description
columns?: 1 | 2 | 3; // Override grid columns for this section
}
export interface EditViewModalAction {
label: string;
icon?: LucideIcon;
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
onClick: () => void | Promise<void>;
disabled?: boolean;
loading?: boolean;
}
export interface EditViewModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'view' | 'edit';
onModeChange?: (mode: 'view' | 'edit') => void;
// Content
title: string;
subtitle?: string;
statusIndicator?: StatusIndicatorConfig;
image?: string;
sections: EditViewModalSection[];
// Actions
actions?: EditViewModalAction[];
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;
// Enhanced features
mobileOptimized?: boolean; // Enable mobile-first responsive design
showStepIndicator?: boolean; // Show step indicator for multi-step workflows
currentStep?: number; // Current step in workflow
totalSteps?: number; // Total steps in workflow
validationErrors?: Record<string, string>; // Field validation errors
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
}
/**
* Format field value based on type
*/
const formatFieldValue = (value: string | number | React.ReactNode, type: EditViewModalField['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: EditViewModalField,
isEditMode: boolean,
onChange?: (value: string | number) => void,
validationError?: string
): React.ReactNode => {
if (!isEditMode || !field.editable) {
return formatFieldValue(field.value, field.type);
}
// Handle custom components
if (field.type === 'component' && field.component) {
const Component = field.component;
return (
<Component
value={field.value}
onChange={onChange}
{...field.componentProps}
/>
);
}
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 (
<div className="w-full">
<textarea
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
onChange={(e) => {
const stringArray = e.target.value.split('\n');
// For list type, we'll pass the joined string instead of array to maintain compatibility
onChange?.(stringArray.join('\n'));
}}
placeholder={field.placeholder || 'Una opción por línea'}
required={field.required}
rows={4}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
validationError
? 'border-red-500 focus:ring-red-500'
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
}`}
/>
{validationError && (
<p className="mt-1 text-sm text-red-600">{validationError}</p>
)}
{field.helpText && !validationError && (
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
)}
</div>
);
case 'textarea':
return (
<div className="w-full">
<textarea
value={String(field.value)}
onChange={(e) => onChange?.(e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={4}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
validationError
? 'border-red-500 focus:ring-red-500'
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
}`}
/>
{validationError && (
<p className="mt-1 text-sm text-red-600">{validationError}</p>
)}
{field.helpText && !validationError && (
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
)}
</div>
);
case 'select':
return (
<div className="w-full">
<Select
value={String(field.value)}
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
options={field.options || []}
placeholder={field.placeholder}
isRequired={field.required}
variant="outline"
size="md"
/>
{validationError && (
<p className="mt-1 text-sm text-red-600">{validationError}</p>
)}
{field.helpText && !validationError && (
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
)}
</div>
);
default:
return (
<div className="w-full">
<Input
type="text"
value={String(inputValue)}
onChange={handleChange}
placeholder={field.placeholder}
required={field.required}
className={`w-full ${
validationError ? 'border-red-500 focus:ring-red-500' : ''
}`}
/>
{validationError && (
<p className="mt-1 text-sm text-red-600">{validationError}</p>
)}
{field.helpText && !validationError && (
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
)}
</div>
);
}
};
/**
* EditViewModal - Unified modal component for viewing/editing data 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 EditViewModal: React.FC<EditViewModalProps> = ({
isOpen,
onClose,
mode,
onModeChange,
title,
subtitle,
statusIndicator,
image,
sections,
actions = [],
showDefaultActions = true,
actionsPosition = 'footer',
onEdit,
onSave,
onCancel,
onFieldChange,
size = 'lg',
loading = false,
// New enhanced features
mobileOptimized = false,
showStepIndicator = false,
currentStep,
totalSteps,
validationErrors = {},
onValidationError,
}) => {
const { t } = useTranslation(['common']);
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: EditViewModalAction[] = [];
if (showDefaultActions) {
if (mode === 'view') {
defaultActions.push({
label: t('common:modals.actions.edit', 'Editar'),
icon: Edit,
variant: 'primary',
onClick: handleEdit,
disabled: loading,
});
} else {
defaultActions.push(
{
label: t('common:modals.actions.cancel', 'Cancelar'),
variant: 'outline',
onClick: handleCancel,
disabled: loading,
},
{
label: t('common:modals.actions.save', 'Guardar'),
variant: 'primary',
onClick: handleSave,
disabled: loading,
loading: loading,
}
);
}
}
const allActions = [...actions, ...defaultActions];
// Step indicator component
const renderStepIndicator = () => {
if (!showStepIndicator || !currentStep || !totalSteps) return null;
return (
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<span>{t('common:modals.step_indicator.step', 'Paso')} {currentStep} {t('common:modals.step_indicator.of', 'de')} {totalSteps}</span>
<div className="flex-1 bg-[var(--bg-tertiary)] rounded-full h-2 mx-3">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
/>
</div>
<span>{Math.round((currentStep / totalSteps) * 100)}%</span>
</div>
</div>
);
};
// 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}
/>
{/* Step Indicator */}
{renderStepIndicator()}
{/* 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)]">{t('common:modals.loading', '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) => {
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
// Determine grid classes based on mobile optimization and section columns
const getGridClasses = () => {
if (mobileOptimized) {
return sectionColumns === 1
? 'grid grid-cols-1 gap-4'
: sectionColumns === 2
? 'grid grid-cols-1 sm:grid-cols-2 gap-4'
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4';
} else {
return sectionColumns === 1
? 'grid grid-cols-1 gap-4'
: sectionColumns === 3
? 'grid grid-cols-1 md:grid-cols-3 gap-4'
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
}
};
return (
<div key={sectionIndex} className="space-y-4">
<div
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
section.collapsible ? 'cursor-pointer' : ''
}`}
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
>
{section.icon && (
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<h3 className="font-medium text-[var(--text-primary)] leading-6">
{section.title}
</h3>
{section.description && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{section.description}
</p>
)}
</div>
{section.collapsible && (
<svg
className={`w-5 h-5 text-[var(--text-secondary)] transition-transform ${
isCollapsed ? 'rotate-0' : 'rotate-180'
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{(!section.collapsible || !isCollapsed) && (
<div className={getGridClasses()}>
{section.fields.map((field, fieldIndex) => {
const fieldKey = `${sectionIndex}-${fieldIndex}`;
const validationError = validationErrors[fieldKey];
return (
<div
key={fieldIndex}
className={`space-y-2 ${
field.span === 2 ?
(mobileOptimized ? 'sm:col-span-2' : 'md:col-span-2') :
field.span === 3 ?
(mobileOptimized ? 'sm:col-span-2 lg:col-span-3' : 'md:col-span-3') :
''
}`}
>
<dt className="text-sm font-medium text-[var(--text-secondary)]">
{field.label}
{field.required && (
<span className="text-red-500 ml-1">*</span>
)}
</dt>
<dd className={`text-sm ${
field.highlight
? 'font-semibold text-[var(--text-primary)]'
: 'text-[var(--text-primary)]'
}`}>
{renderEditableField(
field,
mode === 'edit',
(value: string | number) => {
// Run validation if provided
if (field.validation) {
const error = field.validation(value);
if (error && onValidationError) {
onValidationError({
...validationErrors,
[fieldKey]: error
});
} else if (!error && validationErrors[fieldKey]) {
const newErrors = { ...validationErrors };
delete newErrors[fieldKey];
onValidationError?.(newErrors);
}
}
onFieldChange?.(sectionIndex, fieldIndex, value);
},
validationError
)}
</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 EditViewModal;

View File

@@ -0,0 +1,7 @@
export { EditViewModal, default } from './EditViewModal';
export type {
EditViewModalProps,
EditViewModalField,
EditViewModalSection,
EditViewModalAction
} from './EditViewModal';