ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View File

@@ -0,0 +1,627 @@
import React, { forwardRef, useState, useRef, useEffect, useMemo } from 'react';
import { clsx } from 'clsx';
export interface DatePickerProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
label?: string;
error?: string;
helperText?: string;
placeholder?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'outline' | 'filled' | 'unstyled';
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date | null) => void;
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
isRequired?: boolean;
isInvalid?: boolean;
format?: string;
locale?: 'es' | 'en';
minDate?: Date;
maxDate?: Date;
disabledDates?: Date[];
disablePast?: boolean;
disableFuture?: boolean;
showTime?: boolean;
showToday?: boolean;
showClear?: boolean;
closeOnSelect?: boolean;
firstDayOfWeek?: 0 | 1; // 0 = Sunday, 1 = Monday
monthsToShow?: number;
yearRange?: number;
renderDay?: (date: Date, isSelected: boolean, isToday: boolean, isDisabled: boolean) => React.ReactNode;
renderHeader?: (date: Date, onPrevMonth: () => void, onNextMonth: () => void) => React.ReactNode;
}
const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
label,
error,
helperText,
placeholder = 'Seleccionar fecha',
size = 'md',
variant = 'outline',
value,
defaultValue,
onChange,
onBlur,
onFocus,
isRequired = false,
isInvalid = false,
format = 'dd/mm/yyyy',
locale = 'es',
minDate,
maxDate,
disabledDates = [],
disablePast = false,
disableFuture = false,
showTime = false,
showToday = true,
showClear = true,
closeOnSelect = true,
firstDayOfWeek = 1,
monthsToShow = 1,
yearRange = 100,
renderDay,
renderHeader,
className,
id,
disabled,
...props
}, ref) => {
const datePickerId = id || `datepicker-${Math.random().toString(36).substr(2, 9)}`;
const [internalValue, setInternalValue] = useState<Date | null>(
value !== undefined ? value : defaultValue || null
);
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(
(value || defaultValue || new Date()).getMonth()
);
const [currentYear, setCurrentYear] = useState(
(value || defaultValue || new Date()).getFullYear()
);
const [inputValue, setInputValue] = useState('');
const [timeValue, setTimeValue] = useState('00:00');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Use controlled value if provided, otherwise use internal state
const currentValue = value !== undefined ? value : internalValue;
// Localization
const translations = {
es: {
months: [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
],
weekdays: ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'],
today: 'Hoy',
clear: 'Limpiar',
},
en: {
months: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
today: 'Today',
clear: 'Clear',
},
};
const t = translations[locale];
// Format date for display
const formatDate = (date: Date | null): string => {
if (!date) return '';
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear().toString();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
let formatted = format
.replace(/dd/g, day)
.replace(/mm/g, month)
.replace(/yyyy/g, year);
if (showTime) {
formatted += ` ${hours}:${minutes}`;
}
return formatted;
};
// Parse date from string
const parseDate = (dateString: string): Date | null => {
if (!dateString) return null;
try {
const parts = dateString.split(/[/\-\s:]/);
if (format.startsWith('dd/mm/yyyy')) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const year = parseInt(parts[2], 10);
if (!isNaN(day) && !isNaN(month) && !isNaN(year)) {
const date = new Date(year, month, day);
if (showTime && parts.length >= 5) {
const hours = parseInt(parts[3], 10) || 0;
const minutes = parseInt(parts[4], 10) || 0;
date.setHours(hours, minutes);
}
return date;
}
}
return null;
} catch {
return null;
}
};
// Check if date is disabled
const isDateDisabled = (date: Date): boolean => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
if (disablePast && checkDate < today) return true;
if (disableFuture && checkDate > today) return true;
if (minDate && checkDate < minDate) return true;
if (maxDate && checkDate > maxDate) return true;
return disabledDates.some(disabledDate => {
const disabled = new Date(disabledDate);
disabled.setHours(0, 0, 0, 0);
return disabled.getTime() === checkDate.getTime();
});
};
// Check if 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();
};
// Check if date is selected
const isSelected = (date: Date): boolean => {
if (!currentValue) return false;
return date.getDate() === currentValue.getDate() &&
date.getMonth() === currentValue.getMonth() &&
date.getFullYear() === currentValue.getFullYear();
};
// Get calendar days for current month
const getCalendarDays = useMemo(() => {
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const startDate = new Date(firstDay);
const endDate = new Date(lastDay);
// Adjust for first day of week
const dayOfWeek = firstDay.getDay();
const adjustedDayOfWeek = firstDayOfWeek === 0 ? dayOfWeek : (dayOfWeek + 6) % 7;
startDate.setDate(1 - adjustedDayOfWeek);
// Adjust end date to complete the grid
const daysToAdd = 6 - ((endDate.getDay() - firstDayOfWeek + 7) % 7);
endDate.setDate(endDate.getDate() + daysToAdd);
const days: Date[] = [];
const current = new Date(startDate);
while (current <= endDate) {
days.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return days;
}, [currentMonth, currentYear, firstDayOfWeek]);
// Handle date selection
const handleDateSelect = (date: Date) => {
if (isDateDisabled(date)) return;
let newDate = new Date(date);
// Preserve time if time is shown and a previous value exists
if (showTime && currentValue) {
newDate.setHours(currentValue.getHours(), currentValue.getMinutes());
}
if (value === undefined) {
setInternalValue(newDate);
}
onChange?.(newDate);
if (closeOnSelect && !showTime) {
setIsOpen(false);
}
};
// Handle today button click
const handleTodayClick = () => {
const today = new Date();
if (showTime && currentValue) {
today.setHours(currentValue.getHours(), currentValue.getMinutes());
}
if (value === undefined) {
setInternalValue(today);
}
onChange?.(today);
setCurrentMonth(today.getMonth());
setCurrentYear(today.getFullYear());
if (closeOnSelect && !showTime) {
setIsOpen(false);
}
};
// Handle clear button click
const handleClear = () => {
if (value === undefined) {
setInternalValue(null);
}
onChange?.(null);
setInputValue('');
};
// Handle input change
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
const parsedDate = parseDate(newValue);
if (parsedDate && !isNaN(parsedDate.getTime())) {
if (value === undefined) {
setInternalValue(parsedDate);
}
onChange?.(parsedDate);
}
};
// Handle time change
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTimeValue(newTime);
if (currentValue) {
const [hours, minutes] = newTime.split(':').map(num => parseInt(num, 10));
const newDate = new Date(currentValue);
newDate.setHours(hours, minutes);
if (value === undefined) {
setInternalValue(newDate);
}
onChange?.(newDate);
}
};
// Handle navigation
const handlePrevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const handleNextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
// Close calendar when clicking 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 input value when currentValue changes
useEffect(() => {
if (currentValue) {
setInputValue(formatDate(currentValue));
if (showTime) {
setTimeValue(`${currentValue.getHours().toString().padStart(2, '0')}:${currentValue.getMinutes().toString().padStart(2, '0')}`);
}
} else {
setInputValue('');
}
}, [currentValue, format, showTime]);
const hasError = isInvalid || !!error;
const baseInputClasses = [
'w-full transition-colors duration-200',
'focus:outline-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
'placeholder:text-input-placeholder'
];
const variantClasses = {
outline: [
'bg-input-bg border border-input-border',
'focus:border-input-border-focus focus:ring-1 focus:ring-input-border-focus',
hasError ? 'border-input-border-error focus:border-input-border-error focus:ring-input-border-error' : ''
],
filled: [
'bg-bg-secondary border border-transparent',
'focus:bg-input-bg focus:border-input-border-focus',
hasError ? 'border-input-border-error' : ''
],
unstyled: [
'bg-transparent border-none',
'focus:ring-0'
]
};
const sizeClasses = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-5 text-lg'
};
const inputClasses = clsx(
baseInputClasses,
variantClasses[variant],
sizeClasses[size],
'rounded-lg pr-10',
className
);
const calendarClasses = clsx(
'absolute top-full left-0 z-50 mt-1 bg-dropdown-bg border border-dropdown-border rounded-lg shadow-lg',
'transform transition-all duration-200 ease-out',
{
'opacity-0 scale-95 pointer-events-none': !isOpen,
'opacity-100 scale-100': isOpen,
}
);
return (
<div className="w-full">
{label && (
<label
htmlFor={datePickerId}
className="block text-sm font-medium text-text-primary mb-2"
>
{label}
{isRequired && (
<span className="text-color-error ml-1">*</span>
)}
</label>
)}
<div ref={containerRef} className="relative">
<input
ref={ref || inputRef}
id={datePickerId}
type="text"
className={inputClasses}
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onFocus={(e) => {
setIsOpen(true);
onFocus?.(e);
}}
onBlur={onBlur}
disabled={disabled}
aria-invalid={hasError}
aria-describedby={
error ? `${datePickerId}-error` :
helperText ? `${datePickerId}-helper` :
undefined
}
{...props}
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-text-tertiary hover:text-text-primary transition-colors duration-150"
onClick={() => setIsOpen(!isOpen)}
tabIndex={-1}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</button>
{/* Calendar Popup */}
<div className={calendarClasses}>
<div className="p-4">
{/* Header */}
{renderHeader ? (
renderHeader(new Date(currentYear, currentMonth), handlePrevMonth, handleNextMonth)
) : (
<div className="flex items-center justify-between mb-4">
<button
type="button"
className="p-1 text-text-tertiary hover:text-text-primary transition-colors duration-150"
onClick={handlePrevMonth}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-2">
<select
className="px-2 py-1 text-sm border border-border-primary rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
value={currentMonth}
onChange={(e) => setCurrentMonth(parseInt(e.target.value))}
>
{t.months.map((month, index) => (
<option key={index} value={index}>
{month}
</option>
))}
</select>
<select
className="px-2 py-1 text-sm border border-border-primary rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
value={currentYear}
onChange={(e) => setCurrentYear(parseInt(e.target.value))}
>
{Array.from({ length: yearRange * 2 + 1 }, (_, i) => {
const year = new Date().getFullYear() - yearRange + i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
</div>
<button
type="button"
className="p-1 text-text-tertiary hover:text-text-primary transition-colors duration-150"
onClick={handleNextMonth}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
{/* Weekdays */}
<div className="grid grid-cols-7 gap-1 mb-2">
{(firstDayOfWeek === 0 ? t.weekdays : [...t.weekdays.slice(1), t.weekdays[0]]).map((day) => (
<div key={day} className="text-xs font-medium text-text-tertiary text-center p-2">
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1">
{getCalendarDays.map((date) => {
const isCurrentMonth = date.getMonth() === currentMonth;
const selected = isSelected(date);
const today = isToday(date);
const disabled = isDateDisabled(date);
const dayContent = renderDay ? (
renderDay(date, selected, today, disabled)
) : (
<button
type="button"
className={clsx(
'w-8 h-8 text-sm rounded-full transition-colors duration-150 hover:bg-bg-secondary',
{
'text-text-tertiary': !isCurrentMonth,
'text-text-primary': isCurrentMonth && !selected && !today,
'bg-color-primary text-text-inverse': selected,
'bg-color-primary/10 text-color-primary font-medium': today && !selected,
'opacity-50 cursor-not-allowed': disabled,
'hover:bg-bg-secondary': !disabled && !selected,
}
)}
onClick={() => handleDateSelect(date)}
disabled={disabled}
>
{date.getDate()}
</button>
);
return (
<div key={date.toString()} className="flex items-center justify-center">
{dayContent}
</div>
);
})}
</div>
{/* Time Input */}
{showTime && (
<div className="mt-4 pt-4 border-t border-border-primary">
<label className="block text-sm font-medium text-text-primary mb-2">
Hora
</label>
<input
type="time"
value={timeValue}
onChange={handleTimeChange}
className="w-full px-3 py-2 border border-input-border rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
/>
</div>
)}
{/* Footer */}
{(showToday || showClear) && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-primary">
{showToday && (
<button
type="button"
className="px-3 py-1 text-sm text-color-primary hover:bg-color-primary/10 rounded transition-colors duration-150"
onClick={handleTodayClick}
>
{t.today}
</button>
)}
{showClear && (
<button
type="button"
className="px-3 py-1 text-sm text-text-tertiary hover:text-color-error hover:bg-color-error/10 rounded transition-colors duration-150"
onClick={handleClear}
>
{t.clear}
</button>
)}
</div>
)}
</div>
</div>
</div>
{error && (
<p
id={`${datePickerId}-error`}
className="mt-2 text-sm text-color-error"
>
{error}
</p>
)}
{helperText && !error && (
<p
id={`${datePickerId}-helper`}
className="mt-2 text-sm text-text-secondary"
>
{helperText}
</p>
)}
</div>
);
});
DatePicker.displayName = 'DatePicker';
export default DatePicker;

View File

@@ -0,0 +1,3 @@
export { default } from './DatePicker';
export { default as DatePicker } from './DatePicker';
export type { DatePickerProps } from './DatePicker';