269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
import {
|
|
Button,
|
|
Calendar,
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
FormControl,
|
|
FormDescription,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@repo/shadcn-ui/components";
|
|
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
|
|
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
import { format, isValid, parse } from "date-fns";
|
|
import { useEffect, useState } from "react";
|
|
import { FieldValues } from "react-hook-form";
|
|
import { useTranslation } from "../../../locales/i18n.ts";
|
|
|
|
import { ControllerFieldState, ControllerRenderProps, UseFormStateReturn } from "react-hook-form";
|
|
|
|
export type SUICalendarProps = Omit<React.ComponentProps<
|
|
typeof Calendar>, "select" | "onSelect">
|
|
|
|
type DatePickerInputCompProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
|
field: ControllerRenderProps<TFormValues>;
|
|
fieldState: ControllerFieldState;
|
|
formState: UseFormStateReturn<TFormValues>;
|
|
|
|
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
|
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
|
|
|
label: string;
|
|
placeholder?: string;
|
|
description?: string;
|
|
disabled?: boolean;
|
|
required?: boolean;
|
|
readOnly?: boolean;
|
|
|
|
className?: string;
|
|
};
|
|
|
|
export function DatePickerInputComp<TFormValues extends FieldValues>({
|
|
field,
|
|
fieldState,
|
|
formState,
|
|
|
|
parseDateFormat,
|
|
displayDateFormat,
|
|
|
|
label,
|
|
placeholder,
|
|
description,
|
|
disabled = false,
|
|
required = false,
|
|
readOnly = false,
|
|
className,
|
|
...calendarProps
|
|
}: DatePickerInputCompProps<TFormValues>) {
|
|
const { t } = useTranslation();
|
|
const isDisabled = disabled;
|
|
const isReadOnly = readOnly && !disabled;
|
|
|
|
const describedById = description ? `${field.name}-desc` : undefined;
|
|
const errorId = fieldState.error ? `${field.name}-err` : undefined;
|
|
|
|
const [open, setOpen] = useState(false); // Popover
|
|
const [displayValue, setDisplayValue] = useState<string>("");
|
|
|
|
// Sync cuando RHF actualiza el valor externamente
|
|
useEffect(() => {
|
|
if (field.value) {
|
|
// field.value ya viene en formato parseDateFormat
|
|
console.log(field.value, parseDateFormat);
|
|
const parsed = parse(field.value, parseDateFormat, new Date());
|
|
console.log("parsed =>", parsed);
|
|
if (isValid(parsed)) {
|
|
setDisplayValue(format(parsed, displayDateFormat));
|
|
}
|
|
} else {
|
|
setDisplayValue("");
|
|
}
|
|
}, [field.value, parseDateFormat, displayDateFormat]);
|
|
|
|
const [inputError, setInputError] = useState<string | null>(null);
|
|
|
|
const handleDisplayValueChange = (value: string) => {
|
|
console.log("handleDisplayValueChange => ", value)
|
|
setDisplayValue(value);
|
|
setInputError(null);
|
|
};
|
|
|
|
const handleClearDate = () => {
|
|
handleDisplayValueChange("");
|
|
}
|
|
|
|
const validateAndSetDate = () => {
|
|
const trimmed = displayValue.trim();
|
|
if (!trimmed) {
|
|
field.onChange(""); // guardar vacío en el form
|
|
setInputError(null);
|
|
return;
|
|
}
|
|
|
|
const parsed = parse(trimmed, displayDateFormat, new Date());
|
|
if (isValid(parsed)) {
|
|
// Guardar en form como string con parseDateFormat
|
|
const newDateStr = format(parsed, parseDateFormat);
|
|
field.onChange(newDateStr);
|
|
// Asegurar displayValue consistente
|
|
handleDisplayValueChange(newDateStr);
|
|
} else {
|
|
setInputError(t("components.date_picker_input_field.invalid_date"));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<FormItem className={cn("space-y-0", className)}>
|
|
{label && (
|
|
<div className='mb-1 flex justify-between gap-2'>
|
|
<div className='flex items-center gap-2'>
|
|
<FormLabel
|
|
htmlFor={field.name}
|
|
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
|
>
|
|
{label}
|
|
</FormLabel>
|
|
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
|
|
</div>
|
|
{/* Punto “unsaved” */}
|
|
{fieldState.isDirty && (
|
|
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<FormControl>
|
|
<div className='relative'>
|
|
<input
|
|
type='text'
|
|
value={displayValue}
|
|
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
|
onBlur={() => { if (!open) validateAndSetDate(); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
validateAndSetDate();
|
|
}
|
|
}}
|
|
readOnly={isReadOnly}
|
|
disabled={isDisabled}
|
|
className={cn(
|
|
"w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:font-normal placeholder:italic",
|
|
isDisabled && "bg-muted text-muted-foreground cursor-not-allowed",
|
|
isReadOnly && "bg-muted text-foreground cursor-default",
|
|
!isDisabled && !isReadOnly && "bg-white text-foreground",
|
|
inputError && "border-destructive ring-destructive"
|
|
)}
|
|
placeholder={placeholder}
|
|
/>
|
|
<div className='absolute inset-y-0 right-2 flex items-center gap-2 pr-1'>
|
|
{!isReadOnly && !required && displayValue && (
|
|
<button
|
|
type='button'
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
field.onChange(""); // limpiar valor real en el form
|
|
setDisplayValue(""); // limpiar input visible
|
|
setInputError(null); // limpiar error
|
|
}} aria-label={t("common.clear_date")}
|
|
className='text-muted-foreground hover:text-foreground focus:outline-none'
|
|
>
|
|
<XIcon className='size-4 hover:text-destructive' />
|
|
</button>
|
|
)}
|
|
{isReadOnly ? (
|
|
<LockIcon className='size-4 text-muted-foreground' />
|
|
) : (
|
|
<CalendarIcon className='size-4 text-muted-foreground hover:text-primary hover:cursor-pointer' />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</FormControl>
|
|
</PopoverTrigger>
|
|
|
|
{!isDisabled && !isReadOnly && (
|
|
<PopoverContent className='w-auto p-0'>
|
|
<Card className='border-none shadow-none py-6 gap-3'>
|
|
<CardHeader className="border-b px-3 [.border-b]:pb-3">
|
|
<CardTitle>{label}</CardTitle>
|
|
<CardDescription>{description || "\u00A0"}</CardDescription>
|
|
<CardAction>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
const today = format(new Date(), parseDateFormat);
|
|
field.onChange(today);
|
|
handleDisplayValueChange(today);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{t("components.date_picker_input_field.today")}
|
|
</Button>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent className='px-0'>
|
|
<Calendar
|
|
defaultMonth={field.value ? new Date(field.value) : undefined}
|
|
{...calendarProps}
|
|
mode='single'
|
|
selected={field.value ? new Date(field.value) : undefined}
|
|
onSelect={(date) => {
|
|
const newDateStr = date ? format(date, parseDateFormat) : "";
|
|
field.onChange(newDateStr);
|
|
handleDisplayValueChange(newDateStr);
|
|
setOpen(false);
|
|
}}
|
|
initialFocus
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className='mx-auto'>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{t("components.date_picker_input_field.close")}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</PopoverContent>
|
|
)}
|
|
</Popover>
|
|
|
|
{isReadOnly && (
|
|
<p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
|
|
<LockIcon className='w-3 h-3' /> {t("common.read_only") || "Solo lectura"}
|
|
</p>
|
|
)}
|
|
|
|
<div className='mt-1 flex items-start justify-between'>
|
|
<FormDescription
|
|
id={describedById}
|
|
className={cn("text-xs truncate", !description && "invisible")}
|
|
>
|
|
{description || "\u00A0"}
|
|
</FormDescription>
|
|
</div>
|
|
|
|
<FormMessage id={errorId} />
|
|
</FormItem>
|
|
);
|
|
}
|