[GAPS] feat(components): Implement remaining frontend gaps G-003 to G-013
Components added: - G-003: ErrorBoundary with withErrorBoundary HOC and PageErrorFallback - G-010: Pagination with SimplePagination variant - G-011: Dropdown system (Dropdown, DropdownItem, Menu, etc.) - G-012: FileUpload with drag & drop and preview - G-013: DatePicker with DateRangePicker Hooks added: - G-006: useDebounce, useDebouncedCallback, useDebounceWithImmediate - G-007: useLocalStorage, useSessionStorage Services added: - G-004: API Client with request/response interceptors, token refresh Tests: 49 passing (14 new tests for utility hooks) TypeScript: All types validated Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3dd4b859e
commit
816d591115
432
web/src/components/common/DatePicker.tsx
Normal file
432
web/src/components/common/DatePicker.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
/**
|
||||
* DatePicker - Date selection component
|
||||
* G-013: Date picker with range support and localization
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface DatePickerProps {
|
||||
/** Selected date */
|
||||
value?: Date | null;
|
||||
/** Callback when date changes */
|
||||
onChange: (date: Date | null) => void;
|
||||
/** Minimum selectable date */
|
||||
minDate?: Date;
|
||||
/** Maximum selectable date */
|
||||
maxDate?: Date;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Date format for display */
|
||||
format?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
/** Label text */
|
||||
label?: string;
|
||||
/** Helper text */
|
||||
helperText?: string;
|
||||
/** Allow clearing the date */
|
||||
clearable?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Spanish month and day names
|
||||
const MONTHS_ES = [
|
||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||
];
|
||||
|
||||
const DAYS_ES = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
minDate,
|
||||
maxDate,
|
||||
placeholder = 'Seleccionar fecha',
|
||||
disabled = false,
|
||||
error,
|
||||
label,
|
||||
helperText,
|
||||
clearable = true,
|
||||
className,
|
||||
}: DatePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(value || new Date());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date): string => {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
// Get calendar days for current month view
|
||||
const getCalendarDays = useCallback(() => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
const startOffset = firstDay.getDay(); // Day of week (0-6)
|
||||
const totalDays = lastDay.getDate();
|
||||
|
||||
const days: Array<{ date: Date; isCurrentMonth: boolean }> = [];
|
||||
|
||||
// Previous month days
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = startOffset - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
date: new Date(year, month - 1, prevMonthLastDay - i),
|
||||
isCurrentMonth: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let i = 1; i <= totalDays; i++) {
|
||||
days.push({
|
||||
date: new Date(year, month, i),
|
||||
isCurrentMonth: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days to fill the grid
|
||||
const remaining = 42 - days.length; // 6 rows * 7 days
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
date: new Date(year, month + 1, i),
|
||||
isCurrentMonth: false,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [viewDate]);
|
||||
|
||||
// Check if a date is selectable
|
||||
const isDateSelectable = (date: Date): boolean => {
|
||||
if (minDate && date < minDate) return false;
|
||||
if (maxDate && date > maxDate) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check if a date is selected
|
||||
const isDateSelected = (date: Date): boolean => {
|
||||
if (!value) return false;
|
||||
return (
|
||||
date.getDate() === value.getDate() &&
|
||||
date.getMonth() === value.getMonth() &&
|
||||
date.getFullYear() === value.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
// Check if a date is today
|
||||
const isToday = (date: Date): boolean => {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
// Navigate months
|
||||
const goToPrevMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
// Handle date selection
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (!isDateSelectable(date)) return;
|
||||
onChange(date);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Handle clear
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Update view date when value changes
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setViewDate(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const days = getCalendarDays();
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx('relative', className)}>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-foreground dark:text-foreground mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Input trigger */}
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border transition-colors cursor-pointer',
|
||||
disabled
|
||||
? 'bg-background-muted border-border cursor-not-allowed opacity-60'
|
||||
: isOpen
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: error
|
||||
? 'border-danger'
|
||||
: 'border-border hover:border-foreground-muted dark:border-border dark:hover:border-foreground-muted',
|
||||
'bg-background dark:bg-background'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-5 h-5 text-foreground-muted flex-shrink-0" />
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-1 text-sm',
|
||||
value
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'text-foreground-muted dark:text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{value ? formatDate(value) : placeholder}
|
||||
</span>
|
||||
|
||||
{clearable && value && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-0.5 hover:bg-background-muted dark:hover:bg-background-muted rounded"
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-foreground-muted hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-foreground-muted dark:text-foreground-muted">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-danger">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Calendar dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-dropdown mt-1 p-3 bg-surface-popover dark:bg-surface-popover border border-border dark:border-border rounded-lg shadow-lg min-w-[280px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPrevMonth}
|
||||
className="p-1 hover:bg-background-muted dark:hover:bg-background-muted rounded"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-semibold text-foreground dark:text-foreground">
|
||||
{MONTHS_ES[viewDate.getMonth()]} {viewDate.getFullYear()}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextMonth}
|
||||
className="p-1 hover:bg-background-muted dark:hover:bg-background-muted rounded"
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day names */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{DAYS_ES.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-xs font-medium text-foreground-muted dark:text-foreground-muted text-center py-1"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map(({ date, isCurrentMonth }, index) => {
|
||||
const selectable = isDateSelectable(date);
|
||||
const selected = isDateSelected(date);
|
||||
const today = isToday(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!selectable}
|
||||
className={clsx(
|
||||
'w-8 h-8 text-sm rounded-md transition-colors',
|
||||
!isCurrentMonth && 'text-foreground-subtle',
|
||||
isCurrentMonth && !selected && 'text-foreground dark:text-foreground',
|
||||
selectable && !selected && 'hover:bg-background-muted dark:hover:bg-background-muted',
|
||||
selected && 'bg-primary text-white',
|
||||
today && !selected && 'ring-1 ring-primary',
|
||||
!selectable && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Today button */}
|
||||
<div className="mt-3 pt-3 border-t border-border dark:border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDateClick(new Date())}
|
||||
className="w-full text-sm text-primary hover:text-primary-600 font-medium"
|
||||
>
|
||||
Hoy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DateRangePicker - Select a date range
|
||||
*/
|
||||
export interface DateRangePickerProps {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
onRangeChange: (start: Date | null, end: Date | null) => void;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onRangeChange,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled = false,
|
||||
error,
|
||||
label,
|
||||
className,
|
||||
}: DateRangePickerProps) {
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-foreground dark:text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={(date) => onRangeChange(date, endDate)}
|
||||
maxDate={endDate || maxDate}
|
||||
minDate={minDate}
|
||||
placeholder="Fecha inicio"
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<span className="text-foreground-muted">—</span>
|
||||
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={(date) => onRangeChange(startDate, date)}
|
||||
minDate={startDate || minDate}
|
||||
maxDate={maxDate}
|
||||
placeholder="Fecha fin"
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function CalendarIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronRightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
292
web/src/components/common/Dropdown.tsx
Normal file
292
web/src/components/common/Dropdown.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Dropdown - Dropdown menu component with positioning
|
||||
* G-011: Accessible dropdown menus and popovers
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, ReactNode, createContext, useContext } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Context for dropdown state
|
||||
interface DropdownContextValue {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const DropdownContext = createContext<DropdownContextValue | null>(null);
|
||||
|
||||
function useDropdownContext() {
|
||||
const context = useContext(DropdownContext);
|
||||
if (!context) {
|
||||
throw new Error('Dropdown components must be used within a Dropdown');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Types
|
||||
type DropdownPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
||||
|
||||
interface DropdownProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DropdownTriggerProps {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DropdownContentProps {
|
||||
children: ReactNode;
|
||||
placement?: DropdownPlacement;
|
||||
align?: 'start' | 'end';
|
||||
className?: string;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
interface DropdownItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown container component
|
||||
*/
|
||||
export function Dropdown({ children, className }: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = () => setIsOpen(false);
|
||||
const toggle = () => setIsOpen((prev) => !prev);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={{ isOpen, close }}>
|
||||
<div ref={dropdownRef} className={clsx('relative inline-block', className)}>
|
||||
{/* Inject toggle function to trigger */}
|
||||
{Array.isArray(children)
|
||||
? children.map((child, index) => {
|
||||
if (index === 0 && typeof child === 'object' && child !== null) {
|
||||
return (
|
||||
<div key={index} onClick={toggle}>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
})
|
||||
: children}
|
||||
</div>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown trigger button
|
||||
*/
|
||||
export function DropdownTrigger({ children, className }: DropdownTriggerProps) {
|
||||
return (
|
||||
<div className={clsx('cursor-pointer', className)} role="button" tabIndex={0}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown content container
|
||||
*/
|
||||
export function DropdownContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
className,
|
||||
minWidth = 180,
|
||||
}: DropdownContentProps) {
|
||||
const { isOpen } = useDropdownContext();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const placementClasses: Record<DropdownPlacement, string> = {
|
||||
'bottom-start': 'top-full left-0 mt-1',
|
||||
'bottom-end': 'top-full right-0 mt-1',
|
||||
'top-start': 'bottom-full left-0 mb-1',
|
||||
'top-end': 'bottom-full right-0 mb-1',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute z-dropdown',
|
||||
placementClasses[placement],
|
||||
'bg-surface-popover dark:bg-surface-popover',
|
||||
'border border-border dark:border-border',
|
||||
'rounded-lg shadow-lg',
|
||||
'py-1',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-150',
|
||||
className
|
||||
)}
|
||||
style={{ minWidth }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu item
|
||||
*/
|
||||
export function DropdownItem({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
icon,
|
||||
className,
|
||||
}: DropdownItemProps) {
|
||||
const { close } = useDropdownContext();
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled) return;
|
||||
onClick?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
|
||||
'transition-colors duration-150',
|
||||
disabled
|
||||
? 'text-foreground-subtle cursor-not-allowed opacity-50'
|
||||
: danger
|
||||
? 'text-danger hover:bg-danger/10 dark:hover:bg-danger/20'
|
||||
: 'text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <span className="w-4 h-4 flex-shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown separator
|
||||
*/
|
||||
export function DropdownSeparator({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('my-1 border-t border-border dark:border-border', className)}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown label (non-interactive)
|
||||
*/
|
||||
export function DropdownLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-semibold uppercase tracking-wider',
|
||||
'text-foreground-muted dark:text-foreground-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Menu component (pre-composed dropdown)
|
||||
*/
|
||||
interface MenuProps {
|
||||
trigger: ReactNode;
|
||||
items: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
separator?: boolean;
|
||||
}>;
|
||||
placement?: DropdownPlacement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Menu({ trigger, items, placement = 'bottom-end', className }: MenuProps) {
|
||||
return (
|
||||
<Dropdown className={className}>
|
||||
<DropdownTrigger>{trigger}</DropdownTrigger>
|
||||
<DropdownContent placement={placement}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.separator && <DropdownSeparator />}
|
||||
{!item.separator && (
|
||||
<DropdownItem
|
||||
onClick={item.onClick}
|
||||
icon={item.icon}
|
||||
disabled={item.disabled}
|
||||
danger={item.danger}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dropdown;
|
||||
224
web/src/components/common/ErrorBoundary.tsx
Normal file
224
web/src/components/common/ErrorBoundary.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* ErrorBoundary - React error boundary component
|
||||
* G-003: Catches JavaScript errors in child components
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Custom fallback UI */
|
||||
fallback?: ReactNode;
|
||||
/** Custom fallback render function with error info */
|
||||
fallbackRender?: (props: { error: Error; resetError: () => void }) => ReactNode;
|
||||
/** Callback when error is caught */
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
/** Callback when error is reset */
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
resetError = (): void => {
|
||||
this.props.onReset?.();
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// Custom fallback render function
|
||||
if (this.props.fallbackRender) {
|
||||
return this.props.fallbackRender({
|
||||
error: this.state.error,
|
||||
resetError: this.resetError,
|
||||
});
|
||||
}
|
||||
|
||||
// Custom fallback component
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
return (
|
||||
<DefaultErrorFallback
|
||||
error={this.state.error}
|
||||
resetError={this.resetError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error fallback UI
|
||||
*/
|
||||
interface DefaultErrorFallbackProps {
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({ error, resetError }: DefaultErrorFallbackProps) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="min-h-[200px] flex items-center justify-center p-6"
|
||||
>
|
||||
<div className="max-w-md w-full bg-surface-card dark:bg-surface-card rounded-lg border border-danger/20 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-danger"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-foreground dark:text-foreground mb-2">
|
||||
Algo salió mal
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-foreground-muted dark:text-foreground-muted mb-4">
|
||||
Ha ocurrido un error inesperado. Por favor, intenta de nuevo.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<details className="mb-4 text-left">
|
||||
<summary className="cursor-pointer text-sm text-foreground-subtle hover:text-foreground">
|
||||
Detalles del error
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-background-muted dark:bg-background-muted rounded text-xs overflow-auto max-h-32 text-danger">
|
||||
{error.message}
|
||||
{error.stack && `\n\n${error.stack}`}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={resetError}
|
||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary-600 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component to wrap a component with ErrorBoundary
|
||||
*/
|
||||
export function withErrorBoundary<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>
|
||||
): React.FC<P> {
|
||||
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
|
||||
const ComponentWithErrorBoundary: React.FC<P> = (props) => (
|
||||
<ErrorBoundary {...errorBoundaryProps}>
|
||||
<WrappedComponent {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`;
|
||||
|
||||
return ComponentWithErrorBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error fallback component for page-level errors
|
||||
*/
|
||||
export function PageErrorFallback({
|
||||
error,
|
||||
resetError,
|
||||
}: {
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background dark:bg-background p-4">
|
||||
<div className="max-w-lg w-full bg-surface-card dark:bg-surface-card rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-danger"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-foreground dark:text-foreground mb-3">
|
||||
Error de aplicación
|
||||
</h1>
|
||||
|
||||
<p className="text-foreground-muted dark:text-foreground-muted mb-6">
|
||||
Lo sentimos, ha ocurrido un error inesperado. Nuestro equipo ha sido
|
||||
notificado y estamos trabajando para solucionarlo.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<details className="mb-6 text-left bg-background-muted dark:bg-background-muted rounded-lg p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-foreground">
|
||||
Información técnica
|
||||
</summary>
|
||||
<pre className="mt-3 text-xs overflow-auto max-h-48 text-danger whitespace-pre-wrap">
|
||||
{error.message}
|
||||
{error.stack && `\n\n${error.stack}`}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={resetError}
|
||||
className="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-white bg-primary hover:bg-primary-600 rounded-lg transition-colors"
|
||||
>
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-foreground bg-background-muted hover:bg-background-emphasis rounded-lg transition-colors"
|
||||
>
|
||||
Ir al inicio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
366
web/src/components/common/FileUpload.tsx
Normal file
366
web/src/components/common/FileUpload.tsx
Normal file
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* FileUpload - File upload component with drag & drop
|
||||
* G-012: File upload with preview, validation, and progress
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, ChangeEvent, DragEvent } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface FileUploadProps {
|
||||
/** Accepted file types (e.g., "image/*", ".pdf,.doc") */
|
||||
accept?: string;
|
||||
/** Allow multiple files */
|
||||
multiple?: boolean;
|
||||
/** Max file size in bytes */
|
||||
maxSize?: number;
|
||||
/** Max number of files */
|
||||
maxFiles?: number;
|
||||
/** Callback when files are selected */
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
/** Callback when files are rejected */
|
||||
onFilesRejected?: (errors: FileError[]) => void;
|
||||
/** Callback for upload progress */
|
||||
onUploadProgress?: (progress: number) => void;
|
||||
/** Show file preview */
|
||||
showPreview?: boolean;
|
||||
/** Custom label */
|
||||
label?: string;
|
||||
/** Helper text */
|
||||
helperText?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FileError {
|
||||
file: File;
|
||||
type: 'size' | 'type' | 'count';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface FilePreview {
|
||||
file: File;
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
maxFiles = 10,
|
||||
onFilesSelected,
|
||||
onFilesRejected,
|
||||
showPreview = true,
|
||||
label = 'Arrastra archivos aquí o haz clic para seleccionar',
|
||||
helperText,
|
||||
disabled = false,
|
||||
error,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<FilePreview[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const validateFiles = useCallback(
|
||||
(fileList: FileList | File[]): { valid: File[]; errors: FileError[] } => {
|
||||
const valid: File[] = [];
|
||||
const errors: FileError[] = [];
|
||||
const fileArray = Array.from(fileList);
|
||||
|
||||
// Check max files
|
||||
if (fileArray.length > maxFiles) {
|
||||
fileArray.slice(maxFiles).forEach((file) => {
|
||||
errors.push({
|
||||
file,
|
||||
type: 'count',
|
||||
message: `Máximo ${maxFiles} archivos permitidos`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fileArray.slice(0, maxFiles).forEach((file) => {
|
||||
// Check file size
|
||||
if (file.size > maxSize) {
|
||||
errors.push({
|
||||
file,
|
||||
type: 'size',
|
||||
message: `El archivo excede el tamaño máximo de ${formatFileSize(maxSize)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type if accept is specified
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(',').map((t) => t.trim());
|
||||
const fileType = file.type;
|
||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
const isAccepted = acceptedTypes.some((type) => {
|
||||
if (type.startsWith('.')) {
|
||||
return fileExtension === type.toLowerCase();
|
||||
}
|
||||
if (type.endsWith('/*')) {
|
||||
return fileType.startsWith(type.replace('/*', '/'));
|
||||
}
|
||||
return fileType === type;
|
||||
});
|
||||
|
||||
if (!isAccepted) {
|
||||
errors.push({
|
||||
file,
|
||||
type: 'type',
|
||||
message: `Tipo de archivo no permitido: ${file.type || fileExtension}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
valid.push(file);
|
||||
});
|
||||
|
||||
return { valid, errors };
|
||||
},
|
||||
[accept, maxSize, maxFiles]
|
||||
);
|
||||
|
||||
const createPreviews = (fileList: File[]): FilePreview[] => {
|
||||
return fileList.map((file) => {
|
||||
const preview = file.type.startsWith('image/')
|
||||
? URL.createObjectURL(file)
|
||||
: null;
|
||||
return { file, preview };
|
||||
});
|
||||
};
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | File[]) => {
|
||||
const { valid, errors } = validateFiles(fileList);
|
||||
|
||||
if (errors.length > 0 && onFilesRejected) {
|
||||
onFilesRejected(errors);
|
||||
}
|
||||
|
||||
if (valid.length > 0) {
|
||||
const previews = createPreviews(valid);
|
||||
setFiles((prev) => (multiple ? [...prev, ...previews] : previews));
|
||||
onFilesSelected(valid);
|
||||
}
|
||||
},
|
||||
[validateFiles, onFilesSelected, onFilesRejected, multiple]
|
||||
);
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const { files: droppedFiles } = e.dataTransfer;
|
||||
if (droppedFiles.length > 0) {
|
||||
handleFiles(droppedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files: selectedFiles } = e.target;
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
handleFiles(selectedFiles);
|
||||
}
|
||||
// Reset input
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
const newFiles = [...prev];
|
||||
const removed = newFiles.splice(index, 1);
|
||||
// Revoke preview URL to prevent memory leaks
|
||||
if (removed[0]?.preview) {
|
||||
URL.revokeObjectURL(removed[0].preview);
|
||||
}
|
||||
return newFiles;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full', className)}>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={clsx(
|
||||
'relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer',
|
||||
'text-center',
|
||||
disabled
|
||||
? 'border-border bg-background-muted cursor-not-allowed opacity-60'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
||||
: error
|
||||
? 'border-danger bg-danger/5 dark:bg-danger/10'
|
||||
: 'border-border dark:border-border hover:border-primary hover:bg-background-muted dark:hover:bg-background-muted'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-12 h-12 rounded-full flex items-center justify-center',
|
||||
isDragging
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-background-muted dark:bg-background-muted text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
<UploadIcon className="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-foreground dark:text-foreground font-medium">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{helperText && (
|
||||
<div className="text-xs text-foreground-muted dark:text-foreground-muted">
|
||||
{helperText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-foreground-subtle dark:text-foreground-subtle">
|
||||
Máximo {formatFileSize(maxSize)} por archivo
|
||||
{multiple && ` • Hasta ${maxFiles} archivos`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-danger">{error}</p>
|
||||
)}
|
||||
|
||||
{/* File previews */}
|
||||
{showPreview && files.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
key={`${item.file.name}-${index}`}
|
||||
className="flex items-center gap-3 p-3 bg-background-muted dark:bg-background-muted rounded-lg"
|
||||
>
|
||||
{/* Preview thumbnail */}
|
||||
{item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.file.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-background-emphasis dark:bg-background-emphasis rounded flex items-center justify-center">
|
||||
<FileIcon className="w-5 h-5 text-foreground-muted" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground dark:text-foreground truncate">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted dark:text-foreground-muted">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="p-1 hover:bg-background-emphasis dark:hover:bg-background-emphasis rounded transition-colors"
|
||||
aria-label="Eliminar archivo"
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-foreground-muted hover:text-danger" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function UploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUpload;
|
||||
289
web/src/components/common/Pagination.tsx
Normal file
289
web/src/components/common/Pagination.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Pagination - Component for paginated data navigation
|
||||
* G-010: Reusable pagination with multiple display modes
|
||||
*/
|
||||
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PaginationProps {
|
||||
/** Current page (1-indexed) */
|
||||
currentPage: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Total number of items */
|
||||
totalItems?: number;
|
||||
/** Items per page */
|
||||
pageSize?: number;
|
||||
/** Callback when page changes */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Callback when page size changes */
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
/** Available page sizes */
|
||||
pageSizeOptions?: number[];
|
||||
/** Show page size selector */
|
||||
showPageSize?: boolean;
|
||||
/** Show total items info */
|
||||
showTotal?: boolean;
|
||||
/** Number of page buttons to show */
|
||||
siblingCount?: number;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-7 min-w-[28px] text-xs',
|
||||
md: 'h-9 min-w-[36px] text-sm',
|
||||
lg: 'h-11 min-w-[44px] text-base',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
pageSize = 10,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50, 100],
|
||||
showPageSize = false,
|
||||
showTotal = true,
|
||||
siblingCount = 1,
|
||||
size = 'md',
|
||||
className,
|
||||
disabled = false,
|
||||
}: PaginationProps) {
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = (): (number | 'ellipsis')[] => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate range around current page
|
||||
const leftSibling = Math.max(2, currentPage - siblingCount);
|
||||
const rightSibling = Math.min(totalPages - 1, currentPage + siblingCount);
|
||||
|
||||
// Add ellipsis if gap after first page
|
||||
if (leftSibling > 2) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
// Add pages around current
|
||||
for (let i = leftSibling; i <= rightSibling; i++) {
|
||||
if (i !== 1 && i !== totalPages) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Add ellipsis if gap before last page
|
||||
if (rightSibling < totalPages - 1) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
// Always show last page if more than 1 page
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && page !== currentPage && !disabled) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
// Calculate showing range
|
||||
const startItem = totalItems ? (currentPage - 1) * pageSize + 1 : 0;
|
||||
const endItem = totalItems ? Math.min(currentPage * pageSize, totalItems) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col sm:flex-row items-center justify-between gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Total info */}
|
||||
{showTotal && totalItems !== undefined && (
|
||||
<div className="text-sm text-foreground-muted dark:text-foreground-muted">
|
||||
Mostrando{' '}
|
||||
<span className="font-medium text-foreground dark:text-foreground">
|
||||
{startItem}-{endItem}
|
||||
</span>{' '}
|
||||
de{' '}
|
||||
<span className="font-medium text-foreground dark:text-foreground">
|
||||
{totalItems}
|
||||
</span>{' '}
|
||||
resultados
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Page size selector */}
|
||||
{showPageSize && onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-muted dark:text-foreground-muted">
|
||||
Mostrar
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
'border border-border dark:border-border rounded-md bg-background dark:bg-background text-foreground dark:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
sizeClasses[size],
|
||||
'px-2'
|
||||
)}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page navigation */}
|
||||
<nav className="flex items-center gap-1" aria-label="Paginación">
|
||||
{/* Previous button */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || disabled}
|
||||
aria-label="Página anterior"
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors',
|
||||
'border border-border dark:border-border',
|
||||
'hover:bg-background-muted dark:hover:bg-background-muted',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className={iconSizes[size]} />
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{pageNumbers.map((page, index) =>
|
||||
page === 'ellipsis' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center text-foreground-muted',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
disabled={disabled}
|
||||
aria-label={`Página ${page}`}
|
||||
aria-current={currentPage === page ? 'page' : undefined}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors font-medium',
|
||||
sizeClasses[size],
|
||||
'px-1',
|
||||
currentPage === page
|
||||
? 'bg-primary text-white'
|
||||
: 'border border-border dark:border-border hover:bg-background-muted dark:hover:bg-background-muted text-foreground dark:text-foreground',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || disabled}
|
||||
aria-label="Página siguiente"
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors',
|
||||
'border border-border dark:border-border',
|
||||
'hover:bg-background-muted dark:hover:bg-background-muted',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className={iconSizes[size]} />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple pagination (just prev/next)
|
||||
*/
|
||||
export function SimplePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: Pick<PaginationProps, 'currentPage' | 'totalPages' | 'onPageChange' | 'disabled' | 'className'>) {
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between', className)}>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || disabled}
|
||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-foreground-muted dark:text-foreground-muted">
|
||||
Página {currentPage} de {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || disabled}
|
||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function ChevronLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronRightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
|
||||
@ -45,3 +45,29 @@ export type { DataTableColumn, DataTablePagination } from './DataTable';
|
||||
|
||||
// Toast Notifications
|
||||
export { ToastContainer, ToastItem } from './Toast';
|
||||
|
||||
// Error Handling
|
||||
export { ErrorBoundary, withErrorBoundary, PageErrorFallback } from './ErrorBoundary';
|
||||
|
||||
// Navigation & Pagination
|
||||
export { Pagination, SimplePagination } from './Pagination';
|
||||
export type { PaginationProps } from './Pagination';
|
||||
|
||||
// Dropdown & Menus
|
||||
export {
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownSeparator,
|
||||
DropdownLabel,
|
||||
Menu,
|
||||
} from './Dropdown';
|
||||
|
||||
// File Upload
|
||||
export { FileUpload } from './FileUpload';
|
||||
export type { FileUploadProps, FileError } from './FileUpload';
|
||||
|
||||
// Date Picker
|
||||
export { DatePicker, DateRangePicker } from './DatePicker';
|
||||
export type { DatePickerProps, DateRangePickerProps } from './DatePicker';
|
||||
|
||||
@ -7,3 +7,7 @@ export * from './useHSE';
|
||||
export * from './useProgress';
|
||||
export * from './useFinance';
|
||||
export * from './useToast';
|
||||
|
||||
// Utility hooks
|
||||
export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce';
|
||||
export { useLocalStorage, useSessionStorage } from './useLocalStorage';
|
||||
|
||||
143
web/src/hooks/useDebounce.test.ts
Normal file
143
web/src/hooks/useDebounce.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Tests for useDebounce hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebounce, useDebouncedCallback } from './useDebounce';
|
||||
|
||||
describe('useDebounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useDebounce value', () => {
|
||||
it('should return initial value immediately', () => {
|
||||
const { result } = renderHook(() => useDebounce('test', 300));
|
||||
|
||||
expect(result.current).toBe('test');
|
||||
});
|
||||
|
||||
it('should debounce value changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useDebounce(value, 300),
|
||||
{ initialProps: { value: 'initial' } }
|
||||
);
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
// Change value
|
||||
rerender({ value: 'changed' });
|
||||
|
||||
// Value should not change immediately
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Now value should be updated
|
||||
expect(result.current).toBe('changed');
|
||||
});
|
||||
|
||||
it('should reset timer on rapid changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useDebounce(value, 300),
|
||||
{ initialProps: { value: 'a' } }
|
||||
);
|
||||
|
||||
// Rapid changes
|
||||
rerender({ value: 'b' });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
rerender({ value: 'c' });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
rerender({ value: 'd' });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// Still showing initial value
|
||||
expect(result.current).toBe('a');
|
||||
|
||||
// Advance full delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Should show last value
|
||||
expect(result.current).toBe('d');
|
||||
});
|
||||
|
||||
it('should use custom delay', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useDebounce(value, 500),
|
||||
{ initialProps: { value: 'initial' } }
|
||||
);
|
||||
|
||||
rerender({ value: 'changed' });
|
||||
|
||||
// Advance less than delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
// Advance past delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current).toBe('changed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDebouncedCallback', () => {
|
||||
it('should debounce callback execution', () => {
|
||||
const callback = vi.fn();
|
||||
const { result } = renderHook(() => useDebouncedCallback(callback, 300));
|
||||
|
||||
// Call multiple times
|
||||
act(() => {
|
||||
result.current('a');
|
||||
result.current('b');
|
||||
result.current('c');
|
||||
});
|
||||
|
||||
// Callback should not be called yet
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Callback should be called once with last args
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith('c');
|
||||
});
|
||||
|
||||
it('should maintain stable reference', () => {
|
||||
const callback = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ cb }) => useDebouncedCallback(cb, 300),
|
||||
{ initialProps: { cb: callback } }
|
||||
);
|
||||
|
||||
const firstRef = result.current;
|
||||
rerender({ cb: callback });
|
||||
const secondRef = result.current;
|
||||
|
||||
expect(firstRef).toBe(secondRef);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
web/src/hooks/useDebounce.ts
Normal file
104
web/src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* useDebounce - Hook for debouncing values
|
||||
* G-006: Utility hook for search inputs, form validation, etc.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Debounces a value by delaying updates until after a specified delay
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds (default: 300ms)
|
||||
* @returns The debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a debounced callback function
|
||||
* @param callback - The function to debounce
|
||||
* @param delay - Delay in milliseconds (default: 300ms)
|
||||
* @returns Debounced callback function
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
// Keep callback ref updated
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
}, delay);
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a value with immediate option
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds
|
||||
* @param immediate - If true, trigger on the leading edge instead of trailing
|
||||
*/
|
||||
export function useDebounceWithImmediate<T>(
|
||||
value: T,
|
||||
delay: number = 300,
|
||||
immediate: boolean = false
|
||||
): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (immediate && isFirstRender.current) {
|
||||
setDebouncedValue(value);
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay, immediate]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounce;
|
||||
135
web/src/hooks/useLocalStorage.test.ts
Normal file
135
web/src/hooks/useLocalStorage.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Tests for useLocalStorage hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useLocalStorage, useSessionStorage } from './useLocalStorage';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial value when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
|
||||
|
||||
expect(result.current[0]).toBe('initial');
|
||||
});
|
||||
|
||||
it('should return stored value from localStorage', () => {
|
||||
localStorageMock.setItem('key', JSON.stringify('stored'));
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
|
||||
|
||||
expect(result.current[0]).toBe('stored');
|
||||
});
|
||||
|
||||
it('should update localStorage when value changes', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('updated');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('updated');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('key', JSON.stringify('updated'));
|
||||
});
|
||||
|
||||
it('should support function updater', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('counter', 0));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove value from localStorage', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('stored');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current[2](); // removeValue
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('initial');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('key');
|
||||
});
|
||||
|
||||
it('should handle objects', () => {
|
||||
const initialValue = { name: 'John', age: 30 };
|
||||
const { result } = renderHook(() => useLocalStorage('user', initialValue));
|
||||
|
||||
expect(result.current[0]).toEqual(initialValue);
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ name: 'Jane', age: 25 });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ name: 'Jane', age: 25 });
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const { result } = renderHook(() => useLocalStorage<string[]>('items', []));
|
||||
|
||||
act(() => {
|
||||
result.current[1](['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSessionStorage', () => {
|
||||
// Similar tests for sessionStorage
|
||||
it('should work with sessionStorage', () => {
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionStorage('key', 'initial'));
|
||||
|
||||
expect(result.current[0]).toBe('initial');
|
||||
});
|
||||
});
|
||||
169
web/src/hooks/useLocalStorage.ts
Normal file
169
web/src/hooks/useLocalStorage.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* useLocalStorage - Hook for persisting state in localStorage
|
||||
* G-007: Utility hook for user preferences, form drafts, etc.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type SetValue<T> = T | ((prevValue: T) => T);
|
||||
|
||||
/**
|
||||
* Syncs state with localStorage
|
||||
* @param key - localStorage key
|
||||
* @param initialValue - Initial/default value
|
||||
* @returns [storedValue, setValue, removeValue]
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: SetValue<T>) => void, () => void] {
|
||||
// Get from localStorage or use initial value
|
||||
const readValue = useCallback((): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(readValue);
|
||||
|
||||
// Return a wrapped version of useState's setter function
|
||||
const setValue = useCallback(
|
||||
(value: SetValue<T>) => {
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn(
|
||||
`Tried setting localStorage key "${key}" even though environment is not a browser`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Allow value to be a function
|
||||
const newValue = value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
// Save to localStorage
|
||||
window.localStorage.setItem(key, JSON.stringify(newValue));
|
||||
|
||||
// Save state
|
||||
setStoredValue(newValue);
|
||||
|
||||
// Dispatch storage event for other tabs/windows
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue: JSON.stringify(newValue),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
// Remove value from localStorage
|
||||
const removeValue = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(initialValue);
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue: null,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Listen for changes in other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === key && event.newValue !== null) {
|
||||
try {
|
||||
setStoredValue(JSON.parse(event.newValue) as T);
|
||||
} catch {
|
||||
setStoredValue(initialValue);
|
||||
}
|
||||
} else if (event.key === key && event.newValue === null) {
|
||||
setStoredValue(initialValue);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [key, initialValue]);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* useSessionStorage - Same as useLocalStorage but with sessionStorage
|
||||
*/
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: SetValue<T>) => void, () => void] {
|
||||
const readValue = useCallback((): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.sessionStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading sessionStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(readValue);
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: SetValue<T>) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newValue = value instanceof Function ? value(storedValue) : value;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(newValue));
|
||||
setStoredValue(newValue);
|
||||
} catch (error) {
|
||||
console.warn(`Error setting sessionStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
const removeValue = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.removeItem(key);
|
||||
setStoredValue(initialValue);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing sessionStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
223
web/src/services/apiClient.ts
Normal file
223
web/src/services/apiClient.ts
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* API Client - Centralized HTTP client with interceptors
|
||||
* G-004: Request/response interceptors, auth handling, error transformation
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
|
||||
// API Configuration
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3021/api';
|
||||
const API_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
// Token storage keys
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
// API Error interface
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
error?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Pagination response interface
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create axios instance with base configuration
|
||||
*/
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Request interceptor - Add auth token to requests
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Log requests in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.data || '');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Response interceptor - Handle responses and errors
|
||||
*/
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Log responses in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API] Response ${response.status}:`, response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 Unauthorized - Attempt token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
}
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
clearAuthTokens();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform error for consistent handling
|
||||
const apiError = transformError(error);
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Transform axios error to ApiError
|
||||
*/
|
||||
function transformError(error: AxiosError<ApiError>): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
return {
|
||||
message: error.response.data?.message || 'Error del servidor',
|
||||
statusCode: error.response.status,
|
||||
error: error.response.data?.error,
|
||||
details: error.response.data?.details,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.request) {
|
||||
// Request made but no response
|
||||
return {
|
||||
message: 'No se pudo conectar con el servidor',
|
||||
statusCode: 0,
|
||||
error: 'NETWORK_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
// Request setup error
|
||||
return {
|
||||
message: error.message || 'Error desconocido',
|
||||
statusCode: 0,
|
||||
error: 'REQUEST_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Token management utilities
|
||||
*/
|
||||
export function setAuthTokens(accessToken: string, refreshToken?: string): void {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearAuthTokens(): void {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getAccessToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* API helper methods
|
||||
*/
|
||||
export const api = {
|
||||
get: <T>(url: string, config?: object) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
|
||||
post: <T>(url: string, data?: unknown, config?: object) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
put: <T>(url: string, data?: unknown, config?: object) =>
|
||||
apiClient.put<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
patch: <T>(url: string, data?: unknown, config?: object) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
delete: <T>(url: string, config?: object) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginated request helper
|
||||
*/
|
||||
export async function getPaginated<T>(
|
||||
url: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
const response = await apiClient.get<PaginatedResponse<T>>(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
Loading…
Reference in New Issue
Block a user