Facturas de cliente
This commit is contained in:
parent
3da0d3858f
commit
dc49094f00
@ -1 +1 @@
|
|||||||
export * from "./customer-invoice-edit-form";
|
export * from "./invoice-edit-form";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { FieldErrors, useFormContext } from "react-hook-form";
|
import { FieldErrors, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { FormDebug } from '@erp/core/components';
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { InvoiceFormData } from "../../schemas";
|
import { InvoiceFormData } from "../../schemas";
|
||||||
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
||||||
@ -14,7 +15,7 @@ interface CustomerInvoiceFormProps {
|
|||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerInvoiceEditForm = ({
|
export const InvoiceEditForm = ({
|
||||||
formId,
|
formId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onError,
|
onError,
|
||||||
@ -27,18 +28,18 @@ export const CustomerInvoiceEditForm = ({
|
|||||||
<section className={cn("space-y-6", className)}>
|
<section className={cn("space-y-6", className)}>
|
||||||
<div className="w-full border p-6 bg-background">
|
<div className="w-full border p-6 bg-background">
|
||||||
<InvoiceBasicInfoFields className="flex flex-col" />
|
<InvoiceBasicInfoFields className="flex flex-col" />
|
||||||
|
<InvoiceRecipient className='lg:col-span-1 border p-6 bg-background' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full grid grid-cols-1 lg:grid-cols-4 gap-6'>
|
<div className='w-full gap-6'>
|
||||||
|
<InvoiceItems className="border p-6 bg-background -p-6" />
|
||||||
<InvoiceItems className="col-start-1 lg:col-span-3 border p-6 bg-background -p-6" />
|
|
||||||
<InvoiceRecipient className='lg:col-span-1 border p-6 bg-background' />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full border p-6 bg-background">
|
<div className="w-full border p-6 bg-background">
|
||||||
<InvoiceTotals />
|
<InvoiceTotals />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full border p-6 bg-background">
|
||||||
|
<FormDebug />
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
||||||
import { Control, Controller } from "react-hook-form";
|
import { Control, Controller, FieldValues } from "react-hook-form";
|
||||||
import { useInvoiceContext } from '../../../context';
|
import { useInvoiceContext } from '../../../context';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
||||||
@ -10,9 +10,9 @@ import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
|||||||
import { PercentageInputField } from './percentage-input-field';
|
import { PercentageInputField } from './percentage-input-field';
|
||||||
import { QuantityInputField } from './quantity-input-field';
|
import { QuantityInputField } from './quantity-input-field';
|
||||||
|
|
||||||
export type ItemRowProps = {
|
export type ItemRowProps<TFieldValues extends FieldValues = FieldValues> = {
|
||||||
|
|
||||||
control: Control,
|
control: Control<TFieldValues>,
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
@ -26,8 +26,7 @@ export type ItemRowProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const ItemRow = ({
|
export const ItemRow = <TFieldValues extends FieldValues = FieldValues>({
|
||||||
|
|
||||||
control,
|
control,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
@ -38,7 +37,7 @@ export const ItemRow = ({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
onRemove, }: ItemRowProps) => {
|
onRemove, }: ItemRowProps<TFieldValues>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currency_code, language_code } = useInvoiceContext();
|
const { currency_code, language_code } = useInvoiceContext();
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import * as z from "zod";
|
|||||||
import { CustomerModalSelector } from "@erp/customers/components";
|
import { CustomerModalSelector } from "@erp/customers/components";
|
||||||
|
|
||||||
import { DevTool } from "@hookform/devtools";
|
import { DevTool } from "@hookform/devtools";
|
||||||
import { DatePickerInputField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { useMemo } from 'react';
|
|||||||
import { FieldErrors, FormProvider } from "react-hook-form";
|
import { FieldErrors, FormProvider } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceEditForm,
|
InvoiceEditForm,
|
||||||
PageHeader
|
PageHeader
|
||||||
} from "../../components";
|
} from "../../components";
|
||||||
import { useInvoiceContext } from '../../context';
|
import { useInvoiceContext } from '../../context';
|
||||||
@ -104,7 +104,7 @@ export const InvoiceUpdateComp = ({
|
|||||||
<AppContent>
|
<AppContent>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
|
|
||||||
<CustomerInvoiceEditForm
|
<InvoiceEditForm
|
||||||
formId="invoice-update-form"
|
formId="invoice-update-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { ModuleClientParams } from "@erp/core/client";
|
import { ModuleClientParams } from "@erp/core/client";
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Outlet, RouteObject } from "react-router-dom";
|
import { Outlet, RouteObject } from "react-router-dom";
|
||||||
import { CustomerUpdatePage } from "./pages/update";
|
|
||||||
|
|
||||||
// Lazy load components
|
// Lazy load components
|
||||||
const CustomersLayout = lazy(() =>
|
const CustomersLayout = lazy(() =>
|
||||||
@ -22,11 +21,11 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
|||||||
</CustomersLayout>
|
</CustomersLayout>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: "", index: true, element: <CustomersList /> }, // index
|
/*{ path: "", index: true, element: <CustomersList /> }, // index
|
||||||
{ path: "list", element: <CustomersList /> },
|
{ path: "list", element: <CustomersList /> },
|
||||||
{ path: "create", element: <CustomerAdd /> },
|
{ path: "create", element: <CustomerAdd /> },
|
||||||
{ path: ":id", element: <CustomerView /> },
|
{ path: ":id", element: <CustomerView /> },
|
||||||
{ path: ":id/edit", element: <CustomerUpdatePage /> },
|
{ path: ":id/edit", element: <CustomerUpdatePage /> },*/
|
||||||
|
|
||||||
//
|
//
|
||||||
/*{ path: "create", element: <CustomersList /> },
|
/*{ path: "create", element: <CustomersList /> },
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field, fieldState }) => {
|
render={({ field, fieldState }) => {
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}>
|
||||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function TextField<TFormValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field, fieldState }) => {
|
render={({ field, fieldState }) => {
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}>
|
||||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
import { Button, Input } from '@repo/shadcn-ui/components';
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
// DateInputField.tsx
|
||||||
|
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function DateInputField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlurConfirm,
|
||||||
|
onClear,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
required,
|
||||||
|
hasError,
|
||||||
|
describedBy,
|
||||||
|
onOpenRequest,
|
||||||
|
triggerButton, // ← PopoverTrigger se inyecta aquí
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onBlurConfirm: () => void; // valida+normaliza en blur/Enter
|
||||||
|
onClear: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
required: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
describedBy?: string;
|
||||||
|
onOpenRequest?: () => void;
|
||||||
|
triggerButton?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
pattern="\d{2}/\d{2}/\d{4}"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlurConfirm}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onBlurConfirm();
|
||||||
|
}
|
||||||
|
if ((e.altKey || e.metaKey) && e.key === "ArrowDown") {
|
||||||
|
onOpenRequest?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={hasError || undefined}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
className={cn(
|
||||||
|
"text-ellipsis pr-12",
|
||||||
|
disabled && "bg-muted text-muted-foreground cursor-not-allowed",
|
||||||
|
readOnly && "bg-muted text-foreground cursor-default",
|
||||||
|
!disabled && !readOnly && "bg-white text-foreground",
|
||||||
|
hasError && "border-destructive ring-destructive"
|
||||||
|
)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-2 flex items-center gap-2 pr-1">
|
||||||
|
{!readOnly && !required && value && (
|
||||||
|
<Button
|
||||||
|
variant={'link'}
|
||||||
|
type='button'
|
||||||
|
size={"icon-sm"}
|
||||||
|
onClick={onClear}
|
||||||
|
aria-label="Clear date"
|
||||||
|
className="text-muted-foreground hover:text-destructive transition cursor-pointer -mr-3"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{readOnly ? (
|
||||||
|
<LockIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
triggerButton ?? <CalendarIcon className="size-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,30 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Calendar,
|
Calendar,
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FormControl,
|
|
||||||
Input,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { format, isValid, parse } from "date-fns";
|
import { format, isValid, parse } from "date-fns";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ControllerFieldState, ControllerRenderProps, FieldValues, Path, UseFormStateReturn } from "react-hook-form";
|
import { ControllerFieldState, ControllerRenderProps, FieldValues, Path, UseFormStateReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "../../../locales/i18n.ts";
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
import { DateInputField } from './date-input-field.tsx';
|
||||||
|
import { DatePopoverCalendar } from './date-popover-calendar.tsx';
|
||||||
|
|
||||||
|
|
||||||
export type SUICalendarProps = Omit<React.ComponentProps<
|
export type SUICalendarProps = Omit<React.ComponentProps<
|
||||||
@ -35,8 +27,6 @@ type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = S
|
|||||||
fieldState: ControllerFieldState;
|
fieldState: ControllerFieldState;
|
||||||
formState: UseFormStateReturn<TFormValues>;
|
formState: UseFormStateReturn<TFormValues>;
|
||||||
|
|
||||||
htmlFor: string,
|
|
||||||
|
|
||||||
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
||||||
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
||||||
|
|
||||||
@ -54,13 +44,11 @@ type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = S
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DatePickerInputComp<TFormValues>({
|
export function DatePickerInputComp<TFormValues extends FieldValues = FieldValues>({
|
||||||
field,
|
field,
|
||||||
fieldState,
|
fieldState,
|
||||||
formState,
|
formState,
|
||||||
|
|
||||||
htmlFor,
|
|
||||||
|
|
||||||
parseDateFormat,
|
parseDateFormat,
|
||||||
displayDateFormat,
|
displayDateFormat,
|
||||||
|
|
||||||
@ -78,172 +66,138 @@ export function DatePickerInputComp<TFormValues>({
|
|||||||
...calendarProps
|
...calendarProps
|
||||||
}: DatePickerInputCompProps<TFormValues>) {
|
}: DatePickerInputCompProps<TFormValues>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDisabled = disabled;
|
const [open, setOpen] = useState(false);
|
||||||
const isReadOnly = readOnly && !disabled;
|
const [displayValue, setDisplayValue] = useState("");
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false); // Popover
|
const parsedDate = useMemo(() => {
|
||||||
const [displayValue, setDisplayValue] = useState<string>("");
|
if (!field.value) return null;
|
||||||
|
const d = parse(field.value, parseDateFormat, new Date());
|
||||||
|
return isValid(d) ? d : null;
|
||||||
|
}, [field.value, parseDateFormat]);
|
||||||
|
|
||||||
// Sync cuando RHF actualiza el valor externamente
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (field.value) {
|
setDisplayValue(parsedDate ? format(parsedDate, displayDateFormat) : "");
|
||||||
// field.value ya viene en formato parseDateFormat
|
}, [parsedDate, displayDateFormat]);
|
||||||
const parsed = parse(field.value, parseDateFormat, new Date());
|
|
||||||
if (isValid(parsed)) {
|
const handleClear = useCallback(() => {
|
||||||
setDisplayValue(format(parsed, displayDateFormat));
|
field.onChange("");
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDisplayValue("");
|
setDisplayValue("");
|
||||||
}
|
setLocalError(null);
|
||||||
}, [field.value, parseDateFormat, displayDateFormat]);
|
}, [field]);
|
||||||
|
|
||||||
const [inputError, setInputError] = useState<string | null>(null);
|
const validateAndSet = useCallback(() => {
|
||||||
|
|
||||||
const handleDisplayValueChange = (value: string) => {
|
|
||||||
setDisplayValue(value);
|
|
||||||
setInputError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearDate = () => {
|
|
||||||
handleDisplayValueChange("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateAndSetDate = () => {
|
|
||||||
const trimmed = displayValue.trim();
|
const trimmed = displayValue.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
field.onChange(""); // guardar vacío en el form
|
handleClear();
|
||||||
setInputError(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const d = parse(trimmed, displayDateFormat, new Date());
|
||||||
const parsed = parse(trimmed, displayDateFormat, new Date());
|
if (isValid(d)) {
|
||||||
if (isValid(parsed)) {
|
field.onChange(format(d, parseDateFormat));
|
||||||
// Guardar en form como string con parseDateFormat
|
setDisplayValue(format(d, displayDateFormat));
|
||||||
const newDateStr = format(parsed, parseDateFormat);
|
setLocalError(null);
|
||||||
field.onChange(newDateStr);
|
|
||||||
// Asegurar displayValue consistente
|
|
||||||
handleDisplayValueChange(newDateStr);
|
|
||||||
} else {
|
} else {
|
||||||
setInputError(t("components.date_picker_input_field.invalid_date"));
|
setLocalError(t("components.date_picker_input_field.invalid_date"));
|
||||||
}
|
}
|
||||||
};
|
}, [displayValue, displayDateFormat, parseDateFormat, field, t, handleClear]);
|
||||||
|
|
||||||
|
const hasError = Boolean(localError || fieldState.error);
|
||||||
|
const describedById = description ? `${field.name}-desc` : undefined;
|
||||||
|
const errorId = hasError ? `${field.name}-err` : undefined;
|
||||||
|
const popoverId = `${field.name}-popover`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={invalid} orientation={orientation} className={className}>
|
<Field data-invalid={invalid} orientation={orientation} className={cn("gap-1", className)}>
|
||||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={htmlFor}>{label}</FieldLabel>}
|
<div className="flex justify-between gap-2 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-nowrap">
|
||||||
|
<FieldLabel htmlFor={field.name} className={cn("m-0 text-xs text-muted-foreground text-nowrap text-ellipsis", disabled && "text-muted-foreground")}>
|
||||||
|
{label}
|
||||||
|
</FieldLabel>
|
||||||
|
{required && <span className="text-xs text-destructive">{t("common.required")}</span>}
|
||||||
|
</div>
|
||||||
|
{fieldState.isDirty && <span className="text-[10px] text-primary text-ellipsis">{t("common.modified")}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
|
||||||
<div className='relative'>
|
<DateInputField
|
||||||
<Input
|
id={field.name}
|
||||||
type='text'
|
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
onChange={(v) => {
|
||||||
onBlur={() => { if (!open) validateAndSetDate(); }}
|
setDisplayValue(v);
|
||||||
onKeyDown={(e) => {
|
setLocalError(null);
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
validateAndSetDate();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
readOnly={isReadOnly}
|
onBlurConfirm={validateAndSet}
|
||||||
disabled={isDisabled}
|
onClear={handleClear}
|
||||||
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}
|
placeholder={placeholder}
|
||||||
/>
|
disabled={disabled}
|
||||||
<div className='absolute inset-y-0 right-2 flex items-center gap-2 pr-1'>
|
readOnly={readOnly}
|
||||||
{!isReadOnly && !required && displayValue && (
|
required={required}
|
||||||
<button
|
hasError={hasError}
|
||||||
|
describedBy={[describedById, errorId].filter(Boolean).join(" ") || undefined}
|
||||||
|
onOpenRequest={() => setOpen(true)}
|
||||||
|
triggerButton={
|
||||||
|
!readOnly && !disabled && (
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={'link'}
|
||||||
type='button'
|
type='button'
|
||||||
onClick={(e) => {
|
size={"icon-sm"}
|
||||||
e.preventDefault();
|
aria-label={t("components.date_picker_input_field.open_calendar")}
|
||||||
field.onChange(""); // limpiar valor real en el form
|
aria-haspopup="dialog"
|
||||||
setDisplayValue(""); // limpiar input visible
|
aria-expanded={open}
|
||||||
setInputError(null); // limpiar error
|
aria-controls={popoverId}
|
||||||
}} aria-label={t("common.clear_date")}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className='text-muted-foreground hover:text-foreground focus:outline-none'
|
className="text-muted-foreground transition cursor-pointer -mr-3"
|
||||||
>
|
>
|
||||||
<XIcon className='size-4 hover:text-destructive' />
|
<CalendarIcon className="size-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
{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>
|
</PopoverTrigger>
|
||||||
|
|
||||||
{!isDisabled && !isReadOnly && (
|
{!disabled && !readOnly && (
|
||||||
<PopoverContent className='w-auto p-0'>
|
<DatePopoverCalendar
|
||||||
<Card className='border-none shadow-none py-6 gap-3'>
|
contentId={popoverId}
|
||||||
<CardHeader className="border-b px-3 [.border-b]:pb-3">
|
label={label}
|
||||||
<CardTitle>{label}</CardTitle>
|
description={description}
|
||||||
<CardDescription>{description || "\u00A0"}</CardDescription>
|
parsedDate={parsedDate}
|
||||||
<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) => {
|
onSelect={(date) => {
|
||||||
const newDateStr = date ? format(date, parseDateFormat) : "";
|
if (!date) return;
|
||||||
field.onChange(newDateStr);
|
field.onChange(format(date, parseDateFormat));
|
||||||
handleDisplayValueChange(newDateStr);
|
setDisplayValue(format(date, displayDateFormat));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
initialFocus
|
onToday={() => {
|
||||||
|
const today = new Date();
|
||||||
|
field.onChange(format(today, parseDateFormat));
|
||||||
|
setDisplayValue(format(today, displayDateFormat));
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
{...calendarProps}
|
||||||
/>
|
/>
|
||||||
</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>
|
</Popover>
|
||||||
|
|
||||||
{isReadOnly && (
|
{false && (
|
||||||
<p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
|
<div className='mt-1 flex items-start justify-between'>
|
||||||
<LockIcon className='w-3 h-3' /> {t("common.read_only") || "Solo lectura"}
|
<FieldDescription
|
||||||
</p>
|
id={describedById}
|
||||||
|
className={cn("text-xs truncate", !description && "invisible")}
|
||||||
|
>
|
||||||
|
{description || "\u00A0"}
|
||||||
|
</FieldDescription>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
|
||||||
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ type DatePickerInputFieldProps<TFormValues extends FieldValues> = SUICalendarPro
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
|
||||||
displayDateFormat?: string; // e.g. "dd/MM/yyyy"
|
displayDateFormat?: string; // e.g. "dd-MM-yyyy"
|
||||||
parseDateFormat?: string; // e.g. "yyyy-MM-dd"
|
parseDateFormat?: string; // e.g. "yyyy-MM-dd"
|
||||||
orientation?: "vertical" | "horizontal" | "responsive",
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
};
|
};
|
||||||
@ -22,7 +22,7 @@ type DatePickerInputFieldProps<TFormValues extends FieldValues> = SUICalendarPro
|
|||||||
export function DatePickerInputField<TFormValues extends FieldValues>({
|
export function DatePickerInputField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
displayDateFormat = "dd-MM-y1qyyy",
|
displayDateFormat = "dd-MM-yyyy",
|
||||||
parseDateFormat = "yyyy-MM-dd",
|
parseDateFormat = "yyyy-MM-dd",
|
||||||
...props
|
...props
|
||||||
}: DatePickerInputFieldProps<TFormValues>) {
|
}: DatePickerInputFieldProps<TFormValues>) {
|
||||||
@ -35,8 +35,6 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
fieldState={fieldState}
|
fieldState={fieldState}
|
||||||
formState={formState}
|
formState={formState}
|
||||||
htmlFor={name}
|
|
||||||
|
|
||||||
|
|
||||||
displayDateFormat={displayDateFormat}
|
displayDateFormat={displayDateFormat}
|
||||||
parseDateFormat={parseDateFormat}
|
parseDateFormat={parseDateFormat}
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
// DatePopoverCalendar.tsx
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Calendar,
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
|
||||||
|
export function DatePopoverCalendar({
|
||||||
|
contentId,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
parsedDate,
|
||||||
|
onSelect,
|
||||||
|
onToday,
|
||||||
|
onClose,
|
||||||
|
...calendarProps
|
||||||
|
}: {
|
||||||
|
contentId: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
parsedDate: Date | null;
|
||||||
|
onSelect: (date: Date | undefined) => void;
|
||||||
|
onToday: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
} & Omit<React.ComponentProps<typeof Calendar>, "mode" | "selected" | "onSelect">) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<PopoverContent id={contentId} className="w-auto p-0">
|
||||||
|
<Card className="border-none shadow-none">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle>{label}</CardTitle>
|
||||||
|
<CardDescription>{description || "\u00A0"}</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onToday}
|
||||||
|
>
|
||||||
|
{t("components.date_picker_input_field.today")}
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className='px-0'>
|
||||||
|
<Calendar
|
||||||
|
selected={parsedDate ?? undefined}
|
||||||
|
defaultMonth={parsedDate ?? undefined}
|
||||||
|
onSelect={onSelect}
|
||||||
|
mode='single'
|
||||||
|
numberOfMonths={2}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className='mx-auto'>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}>
|
||||||
|
{t("components.date_picker_input_field.close")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
</PopoverContent >
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1069,8 +1069,8 @@ importers:
|
|||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
react-day-picker:
|
react-day-picker:
|
||||||
specifier: 8.10.1
|
specifier: 9.11.1
|
||||||
version: 8.10.1(date-fns@4.1.0)(react@19.2.0)
|
version: 9.11.1(react@19.2.0)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.0(react@19.2.0)
|
version: 19.2.0(react@19.2.0)
|
||||||
@ -1302,6 +1302,9 @@ packages:
|
|||||||
'@dabh/diagnostics@2.0.8':
|
'@dabh/diagnostics@2.0.8':
|
||||||
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
|
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1':
|
||||||
|
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1':
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -3607,6 +3610,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
date-fns-jalali@4.1.0-0:
|
||||||
|
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||||
|
|
||||||
date-fns@4.1.0:
|
date-fns@4.1.0:
|
||||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
@ -5304,11 +5310,11 @@ packages:
|
|||||||
react: '>= 17.0.0'
|
react: '>= 17.0.0'
|
||||||
styled-components: '>= 5.0.0'
|
styled-components: '>= 5.0.0'
|
||||||
|
|
||||||
react-day-picker@8.10.1:
|
react-day-picker@9.11.1:
|
||||||
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
|
resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
date-fns: ^2.28.0 || ^3.0.0
|
react: '>=16.8.0'
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
|
||||||
|
|
||||||
react-dom@19.2.0:
|
react-dom@19.2.0:
|
||||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||||
@ -6567,6 +6573,8 @@ snapshots:
|
|||||||
enabled: 2.0.0
|
enabled: 2.0.0
|
||||||
kuler: 2.0.0
|
kuler: 2.0.0
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1': {}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
|
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
@ -8897,6 +8905,8 @@ snapshots:
|
|||||||
|
|
||||||
data-uri-to-buffer@6.0.2: {}
|
data-uri-to-buffer@6.0.2: {}
|
||||||
|
|
||||||
|
date-fns-jalali@4.1.0-0: {}
|
||||||
|
|
||||||
date-fns@4.1.0: {}
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
@ -10687,9 +10697,11 @@ snapshots:
|
|||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
styled-components: 6.1.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
styled-components: 6.1.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
|
||||||
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.2.0):
|
react-day-picker@9.11.1(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@date-fns/tz': 1.4.1
|
||||||
date-fns: 4.1.0
|
date-fns: 4.1.0
|
||||||
|
date-fns-jalali: 4.1.0-0
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
||||||
react-dom@19.2.0(react@19.2.0):
|
react-dom@19.2.0(react@19.2.0):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user