Uecko_ERP/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-comp.tsx
2025-10-02 18:57:43 +02:00

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>
);
}