Improve the frontend 2

This commit is contained in:
Urtzi Alfaro
2025-10-29 06:58:05 +01:00
parent 858d985c92
commit 36217a2729
98 changed files with 6652 additions and 4230 deletions

View File

@@ -0,0 +1,485 @@
import React, { useState, useEffect } from 'react';
import { Trash2, AlertTriangle, Info } from 'lucide-react';
import { Modal, Button } from '../index';
export type DeleteMode = 'soft' | 'hard';
export interface EntityDisplayInfo {
primaryText: string;
secondaryText?: string;
}
export interface DeleteModeOption {
mode: DeleteMode;
title: string;
description: string;
benefits: string;
enabled: boolean;
disabledMessage?: string;
}
export interface DeleteWarning {
title: string;
items: string[];
footer?: string;
}
export interface DeletionSummaryData {
[key: string]: string | number;
}
export interface BaseDeleteModalProps<TEntity, TSummary = DeletionSummaryData> {
isOpen: boolean;
onClose: () => void;
entity: TEntity | null;
onSoftDelete: (entityId: string) => Promise<void | TSummary>;
onHardDelete?: (entityId: string) => Promise<void | TSummary>;
isLoading?: boolean;
// Configuration
title: string;
getEntityId: (entity: TEntity) => string;
getEntityDisplay: (entity: TEntity) => EntityDisplayInfo;
// Mode configuration
softDeleteOption: Omit<DeleteModeOption, 'mode' | 'enabled'>;
hardDeleteOption?: Omit<DeleteModeOption, 'mode'>;
// Warnings
softDeleteWarning: DeleteWarning;
hardDeleteWarning: DeleteWarning;
// Optional features
requireConfirmText?: boolean;
confirmText?: string;
showSuccessScreen?: boolean;
successTitle?: string;
getSuccessMessage?: (entity: TEntity, mode: DeleteMode) => string;
// Deletion summary
showDeletionSummary?: boolean;
formatDeletionSummary?: (summary: TSummary) => DeletionSummaryData;
deletionSummaryTitle?: string;
// Dependency checking (for hard delete)
dependencyCheck?: {
isLoading: boolean;
canDelete: boolean;
warnings: string[];
};
// Auto-close timing
autoCloseDelay?: number;
}
export function BaseDeleteModal<TEntity, TSummary = DeletionSummaryData>({
isOpen,
onClose,
entity,
onSoftDelete,
onHardDelete,
isLoading = false,
title,
getEntityId,
getEntityDisplay,
softDeleteOption,
hardDeleteOption,
softDeleteWarning,
hardDeleteWarning,
requireConfirmText = true,
confirmText: customConfirmText = 'ELIMINAR',
showSuccessScreen = false,
successTitle,
getSuccessMessage,
showDeletionSummary = false,
formatDeletionSummary,
deletionSummaryTitle,
dependencyCheck,
autoCloseDelay,
}: BaseDeleteModalProps<TEntity, TSummary>) {
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [deletionResult, setDeletionResult] = useState<TSummary | null>(null);
const [deletionComplete, setDeletionComplete] = useState(false);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setShowConfirmation(false);
setSelectedMode('soft');
setConfirmText('');
setDeletionResult(null);
setDeletionComplete(false);
}
}, [isOpen]);
if (!entity) return null;
const entityDisplay = getEntityDisplay(entity);
const entityId = getEntityId(entity);
const isHardDeleteEnabled = hardDeleteOption?.enabled !== false;
const handleDeleteModeSelect = (mode: DeleteMode) => {
setSelectedMode(mode);
setShowConfirmation(true);
setConfirmText('');
};
const handleConfirmDelete = async () => {
try {
if (selectedMode === 'hard' && onHardDelete) {
const result = await onHardDelete(entityId);
if (result && showDeletionSummary) {
setDeletionResult(result as TSummary);
}
} else {
const result = await onSoftDelete(entityId);
if (result && showDeletionSummary) {
setDeletionResult(result as TSummary);
}
}
if (showSuccessScreen) {
setDeletionComplete(true);
if (autoCloseDelay) {
setTimeout(() => {
handleClose();
}, autoCloseDelay);
}
} else {
handleClose();
}
} catch (error) {
console.error('Error deleting entity:', error);
// Error handling is done by parent component
}
};
const handleClose = () => {
setShowConfirmation(false);
setSelectedMode('soft');
setConfirmText('');
setDeletionResult(null);
setDeletionComplete(false);
onClose();
};
const isConfirmDisabled =
selectedMode === 'hard' &&
requireConfirmText &&
confirmText.toUpperCase() !== customConfirmText.toUpperCase();
// Show deletion result/summary
if (deletionResult && showDeletionSummary && formatDeletionSummary) {
const formattedSummary = formatDeletionSummary(deletionResult);
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{deletionSummaryTitle || 'Eliminación Completada'}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{entityDisplay.primaryText} ha sido eliminado
</p>
</div>
</div>
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
<h4 className="font-medium text-[var(--text-primary)] mb-3">Resumen de eliminación:</h4>
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
{Object.entries(formattedSummary).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span>{key}:</span>
<span className="font-medium">{value}</span>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={handleClose}>
Entendido
</Button>
</div>
</div>
</Modal>
);
}
// Show deletion success
if (deletionComplete && showSuccessScreen) {
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{successTitle || (selectedMode === 'hard' ? 'Eliminado' : 'Desactivado')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{getSuccessMessage?.(entity, selectedMode) || `${entityDisplay.primaryText} procesado correctamente`}
</p>
</div>
</div>
</div>
</Modal>
);
}
// Show confirmation step
if (showConfirmation) {
const isHardDelete = selectedMode === 'hard';
const canDelete = !isHardDelete || !dependencyCheck || dependencyCheck.canDelete !== false;
const warning = isHardDelete ? hardDeleteWarning : softDeleteWarning;
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<div className="p-6">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
{isHardDelete ? (
<AlertTriangle className="w-8 h-8 text-red-500" />
) : (
<Info className="w-8 h-8 text-orange-500" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
{isHardDelete ? 'Confirmación de Eliminación Permanente' : 'Confirmación de Desactivación'}
</h3>
<div className="mb-4">
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
<p className="font-medium text-[var(--text-primary)]">{entityDisplay.primaryText}</p>
{entityDisplay.secondaryText && (
<p className="text-sm text-[var(--text-secondary)]">
{entityDisplay.secondaryText}
</p>
)}
</div>
{isHardDelete && dependencyCheck?.isLoading ? (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
Verificando dependencias...
</p>
</div>
) : isHardDelete && !canDelete ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
No se puede eliminar este elemento
</p>
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
{dependencyCheck?.warnings.map((warn, idx) => (
<li key={idx}> {warn}</li>
))}
</ul>
</div>
) : (
<div className={isHardDelete ? 'text-red-600 dark:text-red-400 mb-4' : 'text-orange-600 dark:text-orange-400 mb-4'}>
<p className="font-medium mb-2">{warning.title}</p>
<ul className="text-sm space-y-1 ml-4">
{warning.items.map((item, idx) => (
<li key={idx}> {item}</li>
))}
</ul>
{warning.footer && (
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
{warning.footer}
</p>
)}
</div>
)}
</div>
{isHardDelete && canDelete && requireConfirmText && (
<div className="mb-6">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Para confirmar, escriba <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">{customConfirmText}</span>:
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
placeholder={`Escriba ${customConfirmText}`}
autoComplete="off"
/>
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmation(false)}
disabled={isLoading}
>
Volver
</Button>
{canDelete && (
<Button
variant={isHardDelete ? 'danger' : 'warning'}
onClick={handleConfirmDelete}
disabled={isConfirmDisabled || isLoading || dependencyCheck?.isLoading}
isLoading={isLoading}
>
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar'}
</Button>
)}
</div>
</div>
</Modal>
);
}
// Initial mode selection
return (
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{title}
</h2>
</div>
<div className="mb-6">
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
<p className="font-medium text-[var(--text-primary)]">{entityDisplay.primaryText}</p>
{entityDisplay.secondaryText && (
<p className="text-sm text-[var(--text-secondary)]">
{entityDisplay.secondaryText}
</p>
)}
</div>
</div>
<div className="mb-6">
<p className="text-[var(--text-primary)] mb-4">
Elija el tipo de eliminación que desea realizar:
</p>
<div className="space-y-4">
{/* Soft Delete Option */}
<div
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'soft'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
: 'border-[var(--border-color)] hover:border-orange-300'
}`}
onClick={() => setSelectedMode('soft')}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className={`w-4 h-4 rounded-full border-2 ${
selectedMode === 'soft'
? 'border-orange-500 bg-orange-500'
: 'border-[var(--border-color)]'
}`}>
{selectedMode === 'soft' && (
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
)}
</div>
</div>
<div>
<h3 className="font-medium text-[var(--text-primary)] mb-1">
{softDeleteOption.title}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{softDeleteOption.description}
</p>
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{softDeleteOption.benefits}
</div>
</div>
</div>
</div>
{/* Hard Delete Option */}
{hardDeleteOption && (
<div
className={`border-2 rounded-lg p-4 transition-colors ${
!isHardDeleteEnabled
? 'opacity-50 cursor-not-allowed border-[var(--border-color)] bg-[var(--background-tertiary)]'
: `cursor-pointer ${
selectedMode === 'hard'
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
: 'border-[var(--border-color)] hover:border-red-300'
}`
}`}
onClick={() => isHardDeleteEnabled && setSelectedMode('hard')}
title={!isHardDeleteEnabled ? hardDeleteOption.disabledMessage : undefined}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className={`w-4 h-4 rounded-full border-2 ${
selectedMode === 'hard' && isHardDeleteEnabled
? 'border-red-500 bg-red-500'
: 'border-[var(--border-color)]'
}`}>
{selectedMode === 'hard' && isHardDeleteEnabled && (
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
)}
</div>
</div>
<div>
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
{hardDeleteOption.title}
<AlertTriangle className="w-4 h-4 text-red-500" />
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{hardDeleteOption.description}
</p>
<div className={`mt-2 text-xs ${
isHardDeleteEnabled
? 'text-red-600 dark:text-red-400'
: 'text-[var(--text-tertiary)]'
}`}>
{isHardDeleteEnabled ? hardDeleteOption.benefits : ` ${hardDeleteOption.disabledMessage}`}
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button
variant={selectedMode === 'hard' && isHardDeleteEnabled ? 'danger' : 'warning'}
onClick={() => handleDeleteModeSelect(selectedMode)}
>
<Trash2 className="w-4 h-4 mr-2" />
Continuar
</Button>
</div>
</div>
</Modal>
);
}
export default BaseDeleteModal;

View File

@@ -0,0 +1,9 @@
export { BaseDeleteModal } from './BaseDeleteModal';
export type {
DeleteMode,
EntityDisplayInfo,
DeleteModeOption,
DeleteWarning,
DeletionSummaryData,
BaseDeleteModalProps,
} from './BaseDeleteModal';

View File

@@ -144,11 +144,7 @@ const renderEditableField = (
onChange?: (value: string | number) => void,
validationError?: string
): React.ReactNode => {
if (!isEditMode || !field.editable) {
return formatFieldValue(field.value, field.type);
}
// Handle custom components
// Handle custom components FIRST - they work in both view and edit modes
if (field.type === 'component' && field.component) {
const Component = field.component;
return (
@@ -160,6 +156,11 @@ const renderEditableField = (
);
}
// Then check if we should render as view or edit
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);
@@ -355,6 +356,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
const StatusIcon = statusIndicator?.icon;
const [isSaving, setIsSaving] = React.useState(false);
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
const [collapsedSections, setCollapsedSections] = React.useState<Record<number, boolean>>({});
// Initialize collapsed states when sections change
React.useEffect(() => {
const initialCollapsed: Record<number, boolean> = {};
sections.forEach((section, index) => {
initialCollapsed[index] = section.collapsed || false;
});
setCollapsedSections(initialCollapsed);
}, [sections]);
const handleEdit = () => {
if (onModeChange) {
@@ -616,7 +627,7 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
<div className="space-y-6">
{sections.map((section, sectionIndex) => {
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
const isCollapsed = collapsedSections[sectionIndex] || false;
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
// Determine grid classes based on mobile optimization and section columns
@@ -642,7 +653,12 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
section.collapsible ? 'cursor-pointer' : ''
}`}
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
onClick={section.collapsible ? () => {
setCollapsedSections(prev => ({
...prev,
[sectionIndex]: !isCollapsed
}));
} : undefined}
>
{section.icon && (
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />

View File

@@ -27,6 +27,7 @@ export { LoadingSpinner } from './LoadingSpinner';
export { EmptyState } from './EmptyState';
export { ResponsiveText } from './ResponsiveText';
export { SearchAndFilter } from './SearchAndFilter';
export { BaseDeleteModal } from './BaseDeleteModal';
// Export types
export type { ButtonProps } from './Button';
@@ -54,4 +55,5 @@ export type { DialogModalProps, DialogModalAction } from './DialogModal';
export type { LoadingSpinnerProps } from './LoadingSpinner';
export type { EmptyStateProps } from './EmptyState';
export type { ResponsiveTextProps } from './ResponsiveText';
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';