[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 { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import {
|
import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
|
|||||||
@ -45,3 +45,29 @@ export type { DataTableColumn, DataTablePagination } from './DataTable';
|
|||||||
|
|
||||||
// Toast Notifications
|
// Toast Notifications
|
||||||
export { ToastContainer, ToastItem } from './Toast';
|
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 './useProgress';
|
||||||
export * from './useFinance';
|
export * from './useFinance';
|
||||||
export * from './useToast';
|
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