Fix modals
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,19 +10,27 @@ import { formatters } from '../Stats/StatsPresets';
|
|||||||
export interface StatusModalField {
|
export interface StatusModalField {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number | React.ReactNode;
|
value: string | number | React.ReactNode;
|
||||||
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select';
|
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component';
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
span?: 1 | 2; // For grid layout
|
span?: 1 | 2 | 3; // For grid layout - added 3 for full width on larger screens
|
||||||
editable?: boolean; // Whether this field can be edited
|
editable?: boolean; // Whether this field can be edited
|
||||||
required?: boolean; // Whether this field is required
|
required?: boolean; // Whether this field is required
|
||||||
placeholder?: string; // Placeholder text for inputs
|
placeholder?: string; // Placeholder text for inputs
|
||||||
options?: Array<{label: string; value: string | number}>; // For select fields
|
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 StatusModalSection {
|
export interface StatusModalSection {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
fields: StatusModalField[];
|
fields: StatusModalField[];
|
||||||
|
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 StatusModalAction {
|
export interface StatusModalAction {
|
||||||
@@ -59,6 +67,14 @@ export interface StatusModalProps {
|
|||||||
// Layout
|
// Layout
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
loading?: boolean;
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,12 +134,25 @@ const formatFieldValue = (value: string | number | React.ReactNode, type: Status
|
|||||||
const renderEditableField = (
|
const renderEditableField = (
|
||||||
field: StatusModalField,
|
field: StatusModalField,
|
||||||
isEditMode: boolean,
|
isEditMode: boolean,
|
||||||
onChange?: (value: string | number) => void
|
onChange?: (value: string | number) => void,
|
||||||
|
validationError?: string
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
if (!isEditMode || !field.editable) {
|
if (!isEditMode || !field.editable) {
|
||||||
return formatFieldValue(field.value, field.type);
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||||
onChange?.(value);
|
onChange?.(value);
|
||||||
@@ -180,41 +209,94 @@ const renderEditableField = (
|
|||||||
);
|
);
|
||||||
case 'list':
|
case 'list':
|
||||||
return (
|
return (
|
||||||
<textarea
|
<div className="w-full">
|
||||||
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
<textarea
|
||||||
onChange={(e) => {
|
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
||||||
const stringArray = e.target.value.split('\n');
|
onChange={(e) => {
|
||||||
// For list type, we'll pass the joined string instead of array to maintain compatibility
|
const stringArray = e.target.value.split('\n');
|
||||||
onChange?.(stringArray.join('\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}
|
placeholder={field.placeholder || 'Una opción por línea'}
|
||||||
rows={4}
|
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"
|
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':
|
case 'select':
|
||||||
return (
|
return (
|
||||||
<Select
|
<div className="w-full">
|
||||||
value={String(field.value)}
|
<Select
|
||||||
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
|
value={String(field.value)}
|
||||||
options={field.options || []}
|
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
|
||||||
placeholder={field.placeholder}
|
options={field.options || []}
|
||||||
isRequired={field.required}
|
placeholder={field.placeholder}
|
||||||
variant="outline"
|
isRequired={field.required}
|
||||||
size="md"
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
<Input
|
<div className="w-full">
|
||||||
type="text"
|
<Input
|
||||||
value={String(inputValue)}
|
type="text"
|
||||||
onChange={handleChange}
|
value={String(inputValue)}
|
||||||
placeholder={field.placeholder}
|
onChange={handleChange}
|
||||||
required={field.required}
|
placeholder={field.placeholder}
|
||||||
className="w-full"
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -249,6 +331,13 @@ export const StatusModal: React.FC<StatusModalProps> = ({
|
|||||||
onFieldChange,
|
onFieldChange,
|
||||||
size = 'lg',
|
size = 'lg',
|
||||||
loading = false,
|
loading = false,
|
||||||
|
// New enhanced features
|
||||||
|
mobileOptimized = false,
|
||||||
|
showStepIndicator = false,
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
validationErrors = {},
|
||||||
|
onValidationError,
|
||||||
}) => {
|
}) => {
|
||||||
const StatusIcon = statusIndicator?.icon;
|
const StatusIcon = statusIndicator?.icon;
|
||||||
|
|
||||||
@@ -311,6 +400,26 @@ export const StatusModal: React.FC<StatusModalProps> = ({
|
|||||||
|
|
||||||
const allActions = [...actions, ...defaultActions];
|
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>Paso {currentStep} 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)
|
// Render top navigation actions (tab-like style)
|
||||||
const renderTopActions = () => {
|
const renderTopActions = () => {
|
||||||
if (actionsPosition !== 'header' || allActions.length === 0) return null;
|
if (actionsPosition !== 'header' || allActions.length === 0) return null;
|
||||||
@@ -411,6 +520,9 @@ export const StatusModal: React.FC<StatusModalProps> = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
{renderStepIndicator()}
|
||||||
|
|
||||||
{/* Top Navigation Actions */}
|
{/* Top Navigation Actions */}
|
||||||
{renderTopActions()}
|
{renderTopActions()}
|
||||||
|
|
||||||
@@ -435,42 +547,121 @@ export const StatusModal: React.FC<StatusModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{sections.map((section, sectionIndex) => (
|
{sections.map((section, sectionIndex) => {
|
||||||
<div key={sectionIndex} className="space-y-4">
|
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
||||||
<div className="flex items-baseline gap-3 pb-3 border-b border-[var(--border-primary)]">
|
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
|
||||||
{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">
|
// Determine grid classes based on mobile optimization and section columns
|
||||||
{section.fields.map((field, fieldIndex) => (
|
const getGridClasses = () => {
|
||||||
<div
|
if (mobileOptimized) {
|
||||||
key={fieldIndex}
|
return sectionColumns === 1
|
||||||
className={`space-y-1 ${field.span === 2 ? 'md:col-span-2' : ''}`}
|
? 'grid grid-cols-1 gap-4'
|
||||||
>
|
: sectionColumns === 2
|
||||||
<dt className="text-sm font-medium text-[var(--text-secondary)]">
|
? 'grid grid-cols-1 sm:grid-cols-2 gap-4'
|
||||||
{field.label}
|
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4';
|
||||||
</dt>
|
} else {
|
||||||
<dd className={`text-sm ${
|
return sectionColumns === 1
|
||||||
field.highlight
|
? 'grid grid-cols-1 gap-4'
|
||||||
? 'font-semibold text-[var(--text-primary)]'
|
: sectionColumns === 3
|
||||||
: 'text-[var(--text-primary)]'
|
? 'grid grid-cols-1 md:grid-cols-3 gap-4'
|
||||||
}`}>
|
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
|
||||||
{renderEditableField(
|
}
|
||||||
field,
|
};
|
||||||
mode === 'edit',
|
|
||||||
(value: string | number) => onFieldChange?.(sectionIndex, fieldIndex, value)
|
return (
|
||||||
)}
|
<div key={sectionIndex} className="space-y-4">
|
||||||
</dd>
|
<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>
|
</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>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user