Customers

This commit is contained in:
David Arranz 2026-04-03 18:15:25 +02:00
parent 836a75dd1e
commit 3ab5216e56
37 changed files with 1013 additions and 801 deletions

View File

@ -61,7 +61,7 @@
"pg-hstore": "^2.3.4",
"reflect-metadata": "^0.2.2",
"response-time": "^2.3.3",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"shallow-equal-object": "^1.1.1",
"ts-node": "^10.9.1",
"uuid": "^11.0.5",

View File

@ -40,11 +40,11 @@
"axios": "^1.9.0",
"dinero.js": "^1.9.1",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"react-hook-form": "^7.72.1",
"react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.2.9",
"vite-plugin-html": "^3.2.2"

View File

@ -32,7 +32,7 @@
"@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.90.6",
"react-hook-form": "^7.56.2",
"react-hook-form": "^7.72.1",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2"
}

View File

@ -47,10 +47,10 @@
"http-status": "^2.1.0",
"lucide-react": "^0.577.0",
"mime-types": "^3.0.1",
"react-hook-form": "^7.58.1",
"react-hook-form": "^7.72.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"zod": "^4.1.11"
}
}

View File

@ -55,11 +55,11 @@
"libphonenumber-js": "^1.12.7",
"lucide-react": "^0.577.0",
"pg-hstore": "^2.3.4",
"react-hook-form": "^7.58.1",
"react-hook-form": "^7.72.1",
"react-i18next": "^15.5.1",
"react-qr-code": "^2.0.18",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"zod": "^4.1.11"
}
}

View File

@ -45,10 +45,10 @@
"express": "^4.18.2",
"lucide-react": "^0.577.0",
"react-data-table-component": "^7.7.0",
"react-hook-form": "^7.58.1",
"react-hook-form": "^7.72.1",
"react-i18next": "^16.2.4",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"use-debounce": "^10.0.5",
"zod": "^4.1.11"
}

View File

@ -58,7 +58,7 @@ export type CustomerPatchProps = Partial<
// Customer
export interface ICustomer {
// comportamiento
update(partialCustomer: CustomerPatchProps): Result<Customer, Error>;
update(partialCustomer: CustomerPatchProps): Result<void, Error>;
// propiedades (getters)
readonly isIndividual: boolean;
@ -148,7 +148,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return new Customer(props, id);
}
public update(partialCustomer: CustomerPatchProps): Result<Customer, Error> {
public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
const { address: partialAddress, ...rest } = partialCustomer;
Object.assign(this.props, rest);

View File

@ -11,7 +11,7 @@ import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction }
import type { CustomerSummary, ICustomerRepository } from "../../../../application";
import type { Customer } from "../../../../domain";
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../../mappers";
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
import { CustomerModel } from "../models/sequelize-customer.model";
export class CustomerRepository

View File

@ -0,0 +1,28 @@
export const COUNTRY_OPTIONS = [
{ value: "es", label: "España" },
{ value: "fr", label: "Francia" },
{ value: "de", label: "Alemania" },
{ value: "it", label: "Italia" },
{ value: "pt", label: "Portugal" },
{ value: "us", label: "Estados Unidos" },
{ value: "mx", label: "México" },
{ value: "ar", label: "Argentina" },
] as const;
export const LANGUAGE_OPTIONS = [
{ value: "es", label: "Español" },
{ value: "en", label: "Inglés" },
{ value: "fr", label: "Francés" },
{ value: "de", label: "Alemán" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Portugués" },
] as const;
export const CURRENCY_OPTIONS = [
{ value: "EUR", label: "Euro" },
{ value: "USD", label: "Dólar estadounidense" },
{ value: "GBP", label: "Libra esterlina" },
{ value: "ARS", label: "Peso argentino" },
{ value: "MXN", label: "Peso mexicano" },
{ value: "JPY", label: "Yen japonés" },
] as const;

View File

@ -0,0 +1 @@
export * from "./customer.constants";

View File

@ -1,4 +1,5 @@
export * from "./api";
export * from "./constants";
export * from "./entities";
export * from "./hooks";
export * from "./ui";

View File

@ -9,8 +9,8 @@ import {
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import type { CustomerFormData } from "../../schemas";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared";
import type { CustomerFormData } from "../../types";
interface CustomerAdditionalConfigFieldsProps {
className?: string;

View File

@ -9,8 +9,8 @@ import {
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { COUNTRY_OPTIONS } from "../../constants";
import type { CustomerFormData } from "../../schemas";
import { COUNTRY_OPTIONS } from "../../../shared";
import type { CustomerFormData } from "../../types";
interface CustomerAddressFieldsProps {
className?: string;

View File

@ -1,30 +1,23 @@
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import { RadioGroupField, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { CustomerFormData } from "../../schemas";
import type { CustomerFormData } from "../../types";
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
interface CustomerBasicInfoFieldsProps {
focusRef?: React.RefObject<HTMLInputElement>;
className?: string;
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {
focusRef?: React.RefObject<HTMLInputElement | null>;
}
export const CustomerBasicInfoFields = ({
@ -33,82 +26,55 @@ export const CustomerBasicInfoFields = ({
...props
}: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
const { control, setFocus } = useFormContext<CustomerFormData>();
// Enfoca el primer campo recibido
useEffect(() => {
focusRef?.current?.focus?.();
}, [focusRef]);
setFocus("name");
}, [setFocus]);
return (
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<Field className="lg:col-span-2" ref={focusRef}>
<TextField
control={control}
description={t("form_fields.name.description")}
label={t("form_fields.name.label")}
name="name"
placeholder={t("form_fields.name.placeholder")}
required
/>
</Field>
<TextField
className="lg:col-span-2"
description={t("form_fields.name.description")}
label={t("form_fields.name.label")}
name="name"
placeholder={t("form_fields.name.placeholder")}
required
/>
<Field className="lg:col-span-1 lg:col-start-1">
<FormField
control={control}
name="is_company"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
className="flex items-center gap-6"
defaultValue={field.value ? "true" : "false"}
onValueChange={(value: string) => {
field.onChange(value === "false" ? "false" : "true");
}}
>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem id="rgi-company" value="true" />
</FormControl>
<FormLabel className="cursor-pointer" htmlFor="rgi-company">
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem id="rgi-individual" value="false" />
</FormControl>
<FormLabel className="cursor-pointer" htmlFor="rgi-individual">
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Field>
<RadioGroupField
className="lg:col-span-1 lg:col-start-1"
description={t("form_fields.customer_type.description")}
items={[
{
value: "true",
label: t("form_fields.customer_type.company"),
},
{
value: "false",
label: t("form_fields.customer_type.individual"),
},
]}
label={t("form_fields.customer_type.label")}
name="is_company"
/>
<Field className="lg:col-span-1 " ref={focusRef}>
<TextField
control={control}
description={t("form_fields.tin.description")}
label={t("form_fields.tin.label")}
name="tin"
placeholder={t("form_fields.tin.placeholder")}
required
/>
</Field>
<TextField
className="lg:col-span-1"
description={t("form_fields.tin.description")}
label={t("form_fields.tin.label")}
name="tin"
placeholder={t("form_fields.tin.placeholder")}
required
/>
<TextField
className="lg:col-span-full"
control={control}
description={t("form_fields.trade_name.description")}
label={t("form_fields.trade_name.label")}
name="trade_name"
@ -117,7 +83,6 @@ export const CustomerBasicInfoFields = ({
<TextField
className="lg:col-span-2 lg:col-start-1"
control={control}
description={t("form_fields.reference.description")}
label={t("form_fields.reference.label")}
name="reference"
@ -129,10 +94,11 @@ export const CustomerBasicInfoFields = ({
control={control}
name="default_taxes"
render={({ field, fieldState }) => (
<Field className={"gap-1"} data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={"default_taxes"}>
<Field className="gap-1" data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="default_taxes">
{t("form_fields.default_taxes.label")}
</FieldLabel>
<CustomerTaxesMultiSelect
description={t("form_fields.default_taxes.description")}
label={t("form_fields.default_taxes.label")}
@ -141,13 +107,14 @@ export const CustomerBasicInfoFields = ({
required
value={field.value}
/>
<FieldDescription>{t("form_fields.default_taxes.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</Field>
<TextAreaField
className="lg:col-span-full"
control={control}
description={t("form_fields.legal_record.description")}
label={t("form_fields.legal_record.label")}
name="legal_record"

View File

@ -34,10 +34,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.email_primary.description")}
icon={
label={t("form_fields.email_primary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
label={t("form_fields.email_primary.label")}
name="email_primary"
placeholder={t("form_fields.email_primary.placeholder")}
required
@ -48,13 +48,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.mobile_primary.description")}
icon={
label={t("form_fields.mobile_primary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
label={t("form_fields.mobile_primary.label")}
name="mobile_primary"
placeholder={t("form_fields.mobile_primary.placeholder")}
typePreset="phone"
@ -64,10 +64,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.phone_primary.description")}
icon={
label={t("form_fields.phone_primary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
label={t("form_fields.phone_primary.label")}
name="phone_primary"
placeholder={t("form_fields.phone_primary.placeholder")}
typePreset="phone"
@ -79,10 +79,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2 lg:col-start-1"
control={control}
description={t("form_fields.email_secondary.description")}
icon={
label={t("form_fields.email_secondary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
label={t("form_fields.email_secondary.label")}
name="email_secondary"
placeholder={t("form_fields.email_secondary.placeholder")}
typePreset="email"
@ -92,13 +92,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.mobile_secondary.description")}
icon={
label={t("form_fields.mobile_secondary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
label={t("form_fields.mobile_secondary.label")}
name="mobile_secondary"
placeholder={t("form_fields.mobile_secondary.placeholder")}
typePreset="phone"
@ -107,10 +107,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.phone_secondary.description")}
icon={
label={t("form_fields.phone_secondary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
label={t("form_fields.phone_secondary.label")}
name="phone_secondary"
placeholder={t("form_fields.phone_secondary.placeholder")}
typePreset="phone"
@ -134,13 +134,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
className="lg:col-span-2"
control={control}
description={t("form_fields.website.description")}
icon={
label={t("form_fields.website.label")}
leftIcon={
<GlobeIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
label={t("form_fields.website.label")}
name="website"
placeholder={t("form_fields.website.placeholder")}
typePreset="text"

View File

@ -17,7 +17,7 @@ export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: Cust
return (
<form id={formId} noValidate onSubmit={onSubmit}>
<FormDebug enabled />
<section className={cn("space-y-12 p-6", className)}>
<section className={cn("space-y-12 p-6 bg-red-800", className)}>
<CustomerBasicInfoFields focusRef={focusRef} />
<CustomerAddressFields />
<CustomerContactFields />

View File

@ -28,7 +28,7 @@
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"express": "^4.18.2",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"zod": "^4.1.11"
}
}

View File

@ -27,7 +27,7 @@
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"express": "^4.18.2",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"zod": "^4.1.11"
}
}

View File

@ -27,7 +27,7 @@
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"express": "^4.18.2",
"sequelize": "^6.37.5",
"sequelize": "^6.37.8",
"zod": "^4.1.11"
}
}

View File

@ -37,5 +37,5 @@
"engines": {
"node": ">=24"
},
"packageManager": "pnpm@10.29.3"
"packageManager": "pnpm@10.33.0"
}

View File

@ -18,6 +18,6 @@
"typescript": "^5.9.3"
},
"dependencies": {
"sequelize": "^6.37.5"
"sequelize": "^6.37.8"
}
}

View File

@ -1,106 +0,0 @@
// DatePickerField.tsx
import {
Button,
Calendar,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Popover,
PopoverContent,
PopoverTrigger,
} from "@repo/shadcn-ui/components";
import { CalendarIcon, LockIcon } from "lucide-react";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { format } from "date-fns";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
type DatePickerFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
placeholder?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
formatDateFn?: (iso: string) => string;
};
export function DatePickerField<TFormValues extends FieldValues>({
control,
name,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
className,
formatDateFn = (iso) => format(new Date(iso), "dd/MM/yyyy"),
}: DatePickerFieldProps<TFormValues>) {
const { t } = useTranslation();
const isDisabled = disabled || readOnly;
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("space-y-0", className)}>
{label && (
<div className='flex justify-between items-center'>
<FormLabel className='m-0'>{label}</FormLabel>
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
</div>
)}
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
disabled={isDisabled}
className={cn(
"w-full justify-start text-left font-normal",
!field.value && "text-muted-foreground",
disabled && "bg-muted text-muted-foreground cursor-not-allowed",
readOnly && !disabled && "bg-muted text-foreground cursor-default"
)}
>
{readOnly ? (
<LockIcon className='mr-2 h-4 w-4 opacity-70' />
) : (
<CalendarIcon className='mr-2 h-4 w-4' />
)}
{field.value ? formatDateFn(field.value) : placeholder}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => {
if (!readOnly) {
field.onChange(date?.toISOString());
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>
{description || "\u00A0"}
</p>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -1,86 +0,0 @@
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
Textarea,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
type Control,
Controller,
type FieldPath,
type FieldValues,
useFormState,
} from "react-hook-form";
import type { CommonInputProps } from "./types.js";
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
required?: boolean;
readOnly?: boolean;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string;
};
export function TextAreaField<TFormValues extends FieldValues>({
control,
name,
label,
description,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
...inputRest
}: TextAreaFieldProps<TFormValues>) {
const { isSubmitting } = useFormState({ control, name });
const disabled = isSubmitting || inputRest.disabled;
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
<Textarea
aria-invalid={fieldState.invalid}
id={name}
onBlur={field.onBlur}
onChange={field.onChange}
ref={field.ref}
value={field.value ?? ""}
{...inputRest}
aria-disabled={disabled}
className={cn(
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
inputClassName
)}
disabled={disabled}
/>
<FieldDescription>{description || "\u00A0"}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/>
);
}

View File

@ -1,86 +0,0 @@
import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
type Control,
Controller,
type FieldPath,
type FieldValues,
useFormState,
} from "react-hook-form";
import type { CommonInputProps } from "./types.js";
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string;
};
export function TextField<TFormValues extends FieldValues>({
control,
name,
label,
description,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
...inputRest
}: TextFieldProps<TFormValues>) {
const { isSubmitting } = useFormState({ control, name });
const disabled = isSubmitting || inputRest.disabled;
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
const form = (e.currentTarget as HTMLInputElement).form;
if (form) form.requestSubmit();
}
}
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
<Input
aria-invalid={fieldState.invalid}
id={name}
onBlur={field.onBlur}
onChange={field.onChange}
onKeyDown={handleKeyDown}
ref={field.ref}
value={field.value ?? ""}
{...inputRest}
aria-disabled={disabled}
className={cn(
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
inputClassName
)}
disabled={disabled}
/>
<FieldDescription>{description || "\u00A0"}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/>
);
}

View File

@ -0,0 +1,149 @@
import {
Button,
Calendar,
Field,
FieldDescription,
FieldError,
FormControl,
FormField,
Popover,
PopoverContent,
PopoverTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { format, isValid, parseISO } from "date-fns";
import { CalendarIcon, LockIcon } from "lucide-react";
import React from "react";
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx";
type DatePickerFieldProps<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
placeholder?: string;
orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string;
formatDateFn?: (value: string) => string;
};
const parseFieldDate = (value?: string): Date | undefined => {
if (!value) return undefined;
const parsed = parseISO(value);
if (!isValid(parsed)) return undefined;
return parsed;
};
const toDateOnlyString = (date: Date): string => {
return format(date, "yyyy-MM-dd");
};
export function DatePickerField<TFormValues extends FieldValues>({
name,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
formatDateFn = (value) => {
const parsed = parseFieldDate(value);
return parsed ? format(parsed, "dd/MM/yyyy") : value;
},
}: DatePickerFieldProps<TFormValues>) {
const triggerId = React.useId();
const { control, formState } = useFormContext<TFormValues>();
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
return (
<FormField
control={control}
name={name}
render={({ field, fieldState }) => {
const selectedDate = parseFieldDate(field.value);
const displayValue = field.value ? formatDateFn(field.value) : null;
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label ? (
<FormFieldLabel htmlFor={triggerId} required={required}>
{label}
</FormFieldLabel>
) : null}
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
aria-invalid={fieldState.invalid}
aria-required={required || undefined}
className={cn(
"h-9 w-full justify-start bg-muted/50 text-left font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
!displayValue && "text-muted-foreground",
inputClassName
)}
disabled={isDisabled}
id={triggerId}
type="button"
variant="outline"
>
{readOnly ? (
<LockIcon aria-hidden="true" className="mr-2 h-4 w-4 opacity-70" />
) : (
<CalendarIcon aria-hidden="true" className="mr-2 h-4 w-4" />
)}
<span className="truncate">{displayValue ?? placeholder ?? "Select date"}</span>
</Button>
</FormControl>
</PopoverTrigger>
{isDisabled ? null : (
<PopoverContent align="start" className="w-auto p-0">
<Calendar
initialFocus
mode="single"
onSelect={(date) => {
field.onChange(date ? toDateOnlyString(date) : "");
field.onBlur();
}}
selected={selectedDate}
/>
</PopoverContent>
)}
</Popover>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/>
);
}

View File

@ -0,0 +1,31 @@
import { FieldLabel } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
interface FormFieldLabelProps extends React.ComponentProps<typeof FieldLabel> {
required?: boolean;
optional?: boolean;
}
export const FormFieldLabel = ({
children,
required = false,
optional = false,
className,
...props
}: FormFieldLabelProps) => {
return (
<FieldLabel className={cn(className)} {...props}>
{children}
{required ? (
<span aria-hidden="true" className="ml-1 text-destructive">
*
</span>
) : null}
{!required && optional ? (
<span className="ml-1 text-muted-foreground text-xs">(optional)</span>
) : null}
</FieldLabel>
);
};

View File

@ -1,7 +1,8 @@
export * from "./DatePickerField.tsx";
export * from "./date-picker-field.tsx";
export * from "./date-picker-input-field/index.ts";
export * from "./form-field-label.tsx";
export * from "./multi-select-field.tsx";
export * from "./SelectField.tsx";
export * from "./TextAreaField.tsx";
export * from "./TextField.tsx";
export type * from "./types.d.ts";
export * from "./radio-group-field.tsx";
export * from "./select-field.tsx";
export * from "./text-area-field.tsx";
export * from "./text-field.tsx";

View File

@ -8,11 +8,10 @@ import {
CommandItem,
CommandList,
CommandSeparator,
Field,
FieldDescription,
FieldError,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
Popover,
PopoverContent,
PopoverTrigger,
@ -20,363 +19,236 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
Loader2Icon,
WandSparklesIcon,
XCircleIcon,
} from "lucide-react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import * as React from "react";
import {
type Control,
type FieldPath,
type FieldValues,
useController,
useFormContext,
useFormState,
} from "react-hook-form";
import { type FieldPath, type FieldValues, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
/* -------------------- Variants -------------------- */
import { FormFieldLabel } from "./form-field-label.tsx";
const multiSelectFieldVariants = cva(
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
{
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-primary hover:bg-primary/80 text-primary-foreground",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
const multiSelectFieldVariants = cva("m-1", {
variants: {
variant: {
default: "border-foreground/10 bg-primary text-primary-foreground",
secondary: "border-foreground/10 bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
inverted: "inverted",
},
defaultVariants: {
variant: "default",
},
}
);
},
defaultVariants: {
variant: "default",
},
});
/* -------------------- Tipos -------------------- */
export type MultiSelectFieldOptionType = {
export interface MultiSelectFieldOptionType {
label: string;
value: string;
group?: string;
icon?: React.ComponentType<{ className?: string }>;
};
/**
* Props base (visuales y de comportamiento)
*/
interface MultiSelectFieldBaseProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectFieldVariants> {
options: MultiSelectFieldOptionType[];
onValueChange?: (values: string[]) => void; // ahora opcional (RHF-first)
onValidateOption?: (value: string) => boolean;
defaultValue?: string[];
placeholder?: string;
animation?: number;
maxCount?: number;
modalPopover?: boolean;
asChild?: boolean;
className?: string;
selectAllVisible?: boolean;
disabled?: boolean;
}
/**
* Props adicionales para paridad con TextField (RHF + UX)
*/
interface MultiSelectFieldTextFieldLikeProps<T extends FieldValues> {
type MultiSelectFieldProps<T extends FieldValues> = VariantProps<
typeof multiSelectFieldVariants
> & {
name: FieldPath<T>;
control?: Control<T>; // opcional; si no, se usa useFormContext()
options: MultiSelectFieldOptionType[];
label?: string;
description?: string;
required?: boolean;
readOnly?: boolean;
disabled?: boolean;
disabledWhileSubmitting?: boolean;
showSuccessWhenValid?: boolean;
showValidatingSpinner?: boolean;
}
placeholder?: string;
maxCount?: number;
modalPopover?: boolean;
selectAllVisible?: boolean;
/**
* Props completas
*/
export type MultiSelectFieldProps<T extends FieldValues> = MultiSelectFieldBaseProps &
MultiSelectFieldTextFieldLikeProps<T>;
className?: string;
inputClassName?: string;
badgeClassName?: string;
/* -------------------- Componente -------------------- */
onValueChange?: (values: string[]) => void;
};
export const MultiSelectFieldInner = React.forwardRef(
<T extends FieldValues>(
{
// RHF-like
name,
control: controlProp,
label,
description,
required,
readOnly,
export const MultiSelectField = <T extends FieldValues>({
name,
options,
label,
description,
required = false,
readOnly = false,
disabled = false,
placeholder,
maxCount = 3,
modalPopover = false,
selectAllVisible = false,
className,
inputClassName,
badgeClassName,
variant,
onValueChange,
}: MultiSelectFieldProps<T>) => {
const { t } = useTranslation();
const triggerId = React.useId();
const { control, formState } = useFormContext<T>();
disabledWhileSubmitting = true,
showSuccessWhenValid = true,
showValidatingSpinner = true,
const { field, fieldState } = useController({
control,
name,
});
// tu API actual
options,
onValueChange,
onValidateOption,
variant,
defaultValue = [],
placeholder,
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
selectAllVisible = false,
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
// button props, etc.
...buttonProps
}: MultiSelectFieldProps<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const formCtx = useFormContext<T>();
const control = controlProp ?? formCtx?.control;
if (!control) {
throw new Error(
"MultiSelectField requiere 'control' o estar dentro de <FormProvider> (useFormContext)."
);
const selectedValues: string[] = Array.isArray(field.value) ? field.value : [];
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
const grouped = options.reduce<Record<string, MultiSelectFieldOptionType[]>>((acc, item) => {
const key = item.group || "";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const emitChange = (values: string[]) => {
field.onChange(values);
onValueChange?.(values);
};
const toggleOption = (optionValue: string) => {
const next = selectedValues.includes(optionValue)
? selectedValues.filter((value) => value !== optionValue)
: [...selectedValues, optionValue];
emitChange(next);
};
const handleClear = () => emitChange([]);
const clearExtraOptions = () => emitChange(selectedValues.slice(0, maxCount));
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear();
return;
}
const { t } = useTranslation();
emitChange(options.filter((option) => !option.disabled).map((option) => option.value));
};
// RHF: estado del campo
const { field, fieldState } = useController({
control,
name,
defaultValue: undefined,
});
const { isSubmitting, isValidating } = useFormState({ control, name });
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation="vertical"
>
{label ? (
<FormFieldLabel htmlFor={triggerId} required={required}>
{label}
</FormFieldLabel>
) : null}
// Inicializa el valor con defaultValue si RHF no trae nada:
React.useEffect(() => {
if (!Array.isArray(field.value) || field.value.length === 0) {
if (defaultValue.length > 0) field.onChange(defaultValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selectedValues: string[] = Array.isArray(field.value) ? field.value : [];
const disabled = (disabledWhileSubmitting && isSubmitting) || buttonProps.disabled || readOnly;
const invalid = fieldState.invalid && (fieldState.isDirty || fieldState.isTouched);
const valid =
!fieldState.invalid &&
(fieldState.isDirty || fieldState.isTouched) &&
selectedValues.length > 0;
// A11y ids
const describedById = description ? `${name}-desc` : undefined;
const errorId = fieldState.error ? `${name}-err` : undefined;
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
// Agrupar opciones
const grouped = options.reduce<Record<string, MultiSelectFieldOptionType[]>>((acc, item) => {
const key = item.group || "";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const emitChange = (values: string[]) => {
field.onChange(values);
onValueChange?.(values);
};
const toggleOption = (option: string) => {
if (onValidateOption && !onValidateOption(option)) {
console.warn(`Option "${option}" is not valid.`);
return;
}
const next = selectedValues.includes(option)
? selectedValues.filter((v) => v !== option)
: [...selectedValues, option];
emitChange(next);
};
const handleClear = () => emitChange([]);
const clearExtraOptions = () => emitChange(selectedValues.slice(0, maxCount));
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear();
} else {
emitChange(options.map((o) => o.value));
}
};
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") setIsPopoverOpen(true);
else if (event.key === "Backspace" && !event.currentTarget.value) {
const next = selectedValues.slice(0, -1);
emitChange(next);
}
};
const buttonAriaDescribedBy = cn(describedById, errorId);
return (
<FormItem className='space-y-0'>
{label && (
<div className='mb-1 flex items-center gap-2'>
<FormLabel className='m-0'>{label}</FormLabel>
{required && <span className='text-xs text-destructive'></span>}
{fieldState.isDirty && (
<span className='text-[10px] text-muted-foreground'>(modificado)</span>
)}
</div>
)}
<FormControl>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
<PopoverTrigger asChild>
<Button
ref={ref}
type='button'
{...buttonProps}
disabled={disabled}
onClick={() => setIsPopoverOpen((p) => !p)}
aria-invalid={invalid || undefined}
aria-describedby={buttonAriaDescribedBy}
aria-errormessage={errorId}
aria-busy={(showValidatingSpinner && isValidating) || undefined}
className={cn(
"flex w-full min-h-10 h-auto items-center justify-between rounded-md border bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto p-1",
invalid && "border-destructive",
valid && showSuccessWhenValid && "border-green-500",
className
)}
>
{/* Contenido del trigger */}
{selectedValues.length > 0 ? (
<div className='flex w-full items-center justify-between'>
<div className='flex flex-wrap items-center'>
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const Icon = option?.icon;
return (
<Badge
key={value}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectFieldVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{Icon && <Icon className='mr-2 h-4 w-4' />}
{option?.label}
</Badge>
);
})}
{selectedValues.length > maxCount && (
<Badge
className={cn(
"bg-primary text-foreground border-foreground/1 hover:bg-primary",
isAnimating ? "animate-bounce" : "",
multiSelectFieldVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selectedValues.length - maxCount} more`}
<XCircleIcon
className='ml-2 h-4 w-4 cursor-pointer'
onClick={(e) => {
e.stopPropagation();
clearExtraOptions();
}}
/>
</Badge>
)}
</div>
<div className='flex items-center gap-2'>
{showValidatingSpinner && isValidating && (
<Loader2Icon className='h-4 w-4 animate-spin' />
)}
{showSuccessWhenValid && valid && !isValidating && !invalid && (
<CheckCircle2Icon className='h-4 w-4 text-green-600' />
)}
<Separator orientation='vertical' className='flex h-full min-h-6' />
<ChevronDownIcon className='mx-2 h-4 cursor-pointer text-muted-foreground' />
</div>
</div>
) : (
<div className='mx-auto flex w-full items-center justify-between'>
<span className='mx-3 text-sm text-muted-foreground'>
{placeholder || t("components.multi_select.select_options")}
</span>
<div className='flex items-center gap-2'>
{showValidatingSpinner && isValidating && (
<Loader2Icon className='h-4 w-4 animate-spin' />
)}
{showSuccessWhenValid && valid && !isValidating && !invalid && (
<CheckCircle2Icon className='h-4 w-4 text-green-600' />
)}
<ChevronDownIcon className='mx-2 h-4 cursor-pointer text-muted-foreground' />
</div>
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className='w-auto p-0'
align='start'
onEscapeKeyDown={() => setIsPopoverOpen(false)}
<FormControl>
<Popover modal={modalPopover} onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
<Button
aria-expanded={isPopoverOpen}
aria-invalid={fieldState.invalid}
aria-required={required || undefined}
className={cn(
"min-h-10 h-auto w-full justify-between bg-muted/50 p-1 text-left font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
inputClassName
)}
disabled={isDisabled}
id={triggerId}
type="button"
variant="outline"
>
{selectedValues.length > 0 ? (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex flex-wrap items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((item) => item.value === value);
const Icon = option?.icon;
return (
<Badge
className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}
key={value}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{option?.label}
</Badge>
);
})}
{selectedValues.length > maxCount ? (
<Badge className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}>
+{selectedValues.length - maxCount}
</Badge>
) : null}
</div>
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
</div>
) : (
<div className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm text-muted-foreground">
{placeholder || t("components.multi_select.select_options")}
</span>
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
</div>
)}
</Button>
</PopoverTrigger>
{isDisabled ? null : (
<PopoverContent align="start" className="w-auto p-0">
<Command>
<CommandInput placeholder={t("common.search")} onKeyDown={handleInputKeyDown} />
<CommandInput placeholder={t("common.search")} />
<CommandList>
<CommandEmpty>{t("components.multi_select.no_results")}</CommandEmpty>
{selectAllVisible && (
<CommandItem key='all' onSelect={toggleAll} className='cursor-pointer'>
{selectAllVisible ? (
<CommandItem className="cursor-pointer" onSelect={toggleAll}>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length
selectedValues.length ===
options.filter((option) => !option.disabled).length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className='h-4 w-4' />
<CheckIcon className="h-4 w-4" />
</div>
<span>(Select All)</span>
<span>{t("common.select_all")}</span>
</CommandItem>
)}
) : null}
{Object.keys(grouped).map((group) => (
<CommandGroup key={`group-${group || "ungrouped"}`} heading={group}>
{grouped[group].map((option) => {
{Object.entries(grouped).map(([group, groupOptions]) => (
<CommandGroup
heading={group || undefined}
key={`group-${group || "ungrouped"}`}
>
{groupOptions.map((option) => {
const isSelected = selectedValues.includes(option.value);
const Icon = option.icon;
const optionDisabled = Boolean(option.disabled);
return (
<CommandItem
className="cursor-pointer"
disabled={optionDisabled}
key={option.value}
onSelect={() => toggleOption(option.value)}
className='cursor-pointer'
onSelect={() => {
if (optionDisabled) return;
toggleOption(option.value);
}}
>
<div
className={cn(
@ -391,7 +263,9 @@ export const MultiSelectFieldInner = React.forwardRef(
)}
/>
</div>
{Icon && <Icon className='mr-2 h-4 w-4 text-muted-foreground' />}
{Icon ? <Icon className="mr-2 h-4 w-4 text-muted-foreground" /> : null}
<span>{option.label}</span>
</CommandItem>
);
@ -402,21 +276,40 @@ export const MultiSelectFieldInner = React.forwardRef(
<CommandSeparator />
<CommandGroup>
<div className='flex items-center justify-between'>
{selectedValues.length > 0 && (
<div className="flex items-center justify-between">
{selectedValues.length > 0 ? (
<>
<CommandItem
className="flex-1 cursor-pointer justify-center"
onSelect={handleClear}
className='flex-1 cursor-pointer justify-center'
>
{t("components.multi_select.clear_selection")}
</CommandItem>
<Separator orientation='vertical' className='flex h-full min-h-6' />
{selectedValues.length > maxCount ? (
<>
<Separator className="flex h-full min-h-6" orientation="vertical" />
<CommandItem
className="flex-1 cursor-pointer justify-center"
onSelect={clearExtraOptions}
>
{t("components.multi_select.keep_first", {
count: maxCount,
})}
</CommandItem>
</>
) : null}
<Separator className="flex h-full min-h-6" orientation="vertical" />
</>
)}
) : null}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className='flex-1 cursor-pointer justify-center'
className="flex-1 cursor-pointer justify-center"
onSelect={() => {
field.onBlur();
setIsPopoverOpen(false);
}}
>
{t("components.multi_select.close")}
</CommandItem>
@ -425,36 +318,17 @@ export const MultiSelectFieldInner = React.forwardRef(
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
</FormControl>
{animation! > 0 && selectedValues.length > 0 && (
<WandSparklesIcon
className={cn(
"my-2 h-3 w-3 cursor-pointer bg-background text-foreground",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating((s) => !s)}
/>
)}
</Popover>
</FormControl>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<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>
);
}
);
MultiSelectFieldInner.displayName = "MultiSelectField";
export const MultiSelectField = MultiSelectFieldInner as <T extends FieldValues>(
p: MultiSelectFieldProps<T> & { ref?: React.Ref<HTMLButtonElement> }
) => React.JSX.Element;
<FieldError errors={[fieldState.error]} />
</Field>
);
};

View File

@ -0,0 +1,139 @@
import {
Field,
FieldDescription,
FieldError,
FormControl,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx";
export interface RadioGroupFieldItem {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
type RadioGroupFieldProps<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
items: RadioGroupFieldItem[];
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string;
itemClassName?: string;
};
export const RadioGroupField = <TFormValues extends FieldValues>({
name,
items,
label,
description,
disabled = false,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
itemClassName,
}: RadioGroupFieldProps<TFormValues>) => {
const baseId = React.useId();
const { control, formState } = useFormContext<TFormValues>();
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label ? (
<FormFieldLabel
htmlFor={`${baseId}-${items[0]?.value ?? "option"}`}
required={required}
>
{label}
</FormFieldLabel>
) : null}
<FormControl>
<RadioGroup
className={cn(
"gap-3",
orientation === "horizontal" && "flex flex-row flex-wrap items-center gap-6",
orientation === "responsive" &&
"flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-6",
inputClassName
)}
disabled={isDisabled}
name={field.name}
onValueChange={field.onChange}
value={field.value ?? ""}
>
{items.map((item, index) => {
const itemId = `${baseId}-${index}-${item.value}`;
const itemDisabled = Boolean(isDisabled || item.disabled);
return (
<div className={cn("flex items-start gap-2", itemClassName)} key={item.value}>
<RadioGroupItem
aria-invalid={fieldState.invalid}
aria-required={required || undefined}
className="mt-0.5"
disabled={itemDisabled}
id={itemId}
onBlur={field.onBlur}
value={item.value}
/>
<div className="grid gap-0.5">
<label
className={cn(
"cursor-pointer text-sm font-medium leading-none",
itemDisabled && "cursor-not-allowed opacity-70"
)}
htmlFor={itemId}
>
{item.label}
</label>
{item.description ? (
<p className="text-muted-foreground text-sm">{item.description}</p>
) : null}
</div>
</div>
);
})}
</RadioGroup>
</FormControl>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
);
};

View File

@ -2,7 +2,6 @@ import {
Field,
FieldDescription,
FieldError,
FieldLabel,
FormControl,
Select,
SelectContent,
@ -11,28 +10,28 @@ import {
SelectValue,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
type Control,
Controller,
type FieldPath,
type FieldValues,
useController,
useFormState,
} from "react-hook-form";
import React from "react";
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
import { FormFieldLabel } from "./form-field-label.tsx";
interface SelectFieldItem {
value: string;
label: string;
}
type SelectFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
placeholder?: string;
items: Array<{ value: string; label: string }>;
items: SelectFieldItem[];
orientation?: "vertical" | "horizontal" | "responsive";
@ -41,27 +40,26 @@ type SelectFieldProps<TFormValues extends FieldValues> = {
};
export function SelectField<TFormValues extends FieldValues>({
control,
name,
items,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
placeholder,
items,
orientation = "vertical",
className,
inputClassName,
}: SelectFieldProps<TFormValues>) {
const { t } = useTranslation();
const { isSubmitting } = useFormState({ control, name });
const { field, fieldState } = useController({ control, name });
const isDisabled = disabled || readOnly;
const triggerId = React.useId();
const { control, formState } = useFormContext<TFormValues>();
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
return (
<Controller
@ -74,16 +72,28 @@ export function SelectField<TFormValues extends FieldValues>({
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
{label ? (
<FormFieldLabel htmlFor={triggerId} required={required}>
{label}
</FormFieldLabel>
) : null}
<Select defaultValue={field.value} disabled={isDisabled} onValueChange={field.onChange}>
<Select
disabled={isDisabled}
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger
aria-invalid={fieldState.invalid}
aria-required={required}
className={cn(
"w-full h-8",
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
"bg-muted/50 font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
inputClassName
)}
id={triggerId}
>
<SelectValue
className={"placeholder:font-normal placeholder:italic"}
@ -100,7 +110,11 @@ export function SelectField<TFormValues extends FieldValues>({
</SelectContent>
</Select>
<FieldDescription>{description || "\u00A0"}</FieldDescription>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldState.error]} />
</Field>
);

View File

@ -0,0 +1,89 @@
import { Field, FieldDescription, FieldError, Textarea } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx";
import type { NativeTextareaProps } from "./types.ts";
type TextAreaFieldProps<TFormValues extends FieldValues> = NativeTextareaProps & {
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string;
};
export function TextAreaField<TFormValues extends FieldValues>({
name,
label,
description,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
...inputRest
}: TextAreaFieldProps<TFormValues>) {
const { register, formState, getFieldState } = useFormContext<TFormValues>();
const inputId = React.useId();
const disabled = formState.isSubmitting || inputRest.disabled;
// Obtener error del campo (tipado seguro)
const fieldError = getFieldState(name, formState).error;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
inputRest.onKeyDown?.(e);
if (e.defaultPrevented) return;
// Cmd/Ctrl + Enter para submit
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
return (
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
{label ? (
<FormFieldLabel htmlFor={inputId} required={required}>
{label}
</FormFieldLabel>
) : null}
<Textarea
{...inputRest}
{...register(name)}
aria-invalid={!!fieldError}
className={cn(
"bg-muted/50 font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
inputClassName
)}
disabled={disabled}
id={inputId}
onKeyDown={handleKeyDown}
readOnly={readOnly}
required={required}
/>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldError]} />
</Field>
);
}

View File

@ -0,0 +1,82 @@
export type TextFieldTypePreset =
| "text"
| "email"
| "phone"
| "search"
| "url"
| "password"
| "number";
export interface ResolvedInputPreset {
type: React.HTMLInputTypeAttribute;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
autoComplete?: string;
spellCheck?: boolean;
enterKeyHint?: React.HTMLAttributes<HTMLInputElement>["enterKeyHint"];
}
export const getInputPresetProps = (preset: TextFieldTypePreset = "text"): ResolvedInputPreset => {
switch (preset) {
case "email":
return {
type: "email",
inputMode: "email",
autoComplete: "email",
spellCheck: false,
enterKeyHint: "next",
};
case "phone":
return {
type: "tel",
inputMode: "tel",
autoComplete: "tel",
spellCheck: false,
enterKeyHint: "next",
};
case "search":
return {
type: "search",
inputMode: "search",
autoComplete: "off",
spellCheck: false,
enterKeyHint: "search",
};
case "url":
return {
type: "url",
inputMode: "url",
autoComplete: "url",
spellCheck: false,
enterKeyHint: "next",
};
case "password":
return {
type: "password",
autoComplete: "current-password",
spellCheck: false,
enterKeyHint: "done",
};
case "number":
return {
type: "text",
inputMode: "numeric",
autoComplete: "off",
spellCheck: false,
enterKeyHint: "next",
};
case "text":
default:
return {
type: "text",
autoComplete: "off",
spellCheck: false,
enterKeyHint: "next",
};
}
};

View File

@ -0,0 +1,109 @@
import {
Field,
FieldDescription,
FieldError,
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx";
import { type TextFieldTypePreset, getInputPresetProps } from "./text-field-presets.tsx";
import type { NativeInputProps } from "./types.ts";
type TextFieldProps<TFormValues extends FieldValues> = NativeInputProps & {
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
typePreset?: TextFieldTypePreset;
};
export const TextField = <TFormValues extends FieldValues>({
name,
label,
description,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
leftIcon,
rightIcon,
typePreset,
...inputRest
}: TextFieldProps<TFormValues>) => {
const { register, formState, getFieldState } = useFormContext<TFormValues>();
const inputId = React.useId();
const disabled = formState.isSubmitting || inputRest.disabled;
const presetProps = getInputPresetProps(typePreset);
// Obtener error del campo (tipado seguro)
const fieldError = getFieldState(name, formState).error;
return (
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
{label ? (
<FormFieldLabel htmlFor={inputId} required={required}>
{label}
</FormFieldLabel>
) : null}
<InputGroup
className={cn(
"bg-muted/50 font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
inputClassName
)}
>
{leftIcon && (
<InputGroupAddon aria-hidden="true" className={cn("bg-muted/50 font-medium")}>
{leftIcon}
</InputGroupAddon>
)}
<InputGroupInput
{...presetProps}
{...inputRest}
{...register(name)}
aria-invalid={!!fieldError}
disabled={disabled}
id={inputId}
readOnly={readOnly}
required={required}
/>
{rightIcon && <InputGroupAddon aria-hidden="true">{rightIcon}</InputGroupAddon>}
</InputGroup>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldError]} />
</Field>
);
};

View File

@ -1,4 +0,0 @@
export type CommonInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
>;

View File

@ -0,0 +1,9 @@
export type NativeInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"name" | "value" | "defaultValue" | "onChange" | "type"
>;
export type NativeTextareaProps = Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"name" | "value" | "defaultValue" | "onChange"
>;

View File

@ -56,7 +56,7 @@
"pnpm": "^10.10.0",
"radix-ui": "latest",
"react-day-picker": "9.14.0",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.1",
"react-resizable-panels": "^4.8.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",

View File

@ -250,8 +250,8 @@ importers:
specifier: ^6.0.0
version: 6.0.0(react@19.2.0)
react-hook-form:
specifier: ^7.56.4
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-i18next:
specifier: ^15.0.1
version: 15.7.4(i18next@25.6.0(typescript@5.8.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.8.3)
@ -326,8 +326,8 @@ importers:
specifier: ^5.90.6
version: 5.90.6(react@19.2.0)
react-hook-form:
specifier: ^7.56.2
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-router-dom:
specifier: ^6.26.0
version: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -361,7 +361,7 @@ importers:
dependencies:
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
@ -405,8 +405,8 @@ importers:
specifier: ^3.0.1
version: 3.0.1
react-hook-form:
specifier: ^7.58.1
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-i18next:
specifier: ^15.5.1
version: 15.7.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@ -467,7 +467,7 @@ importers:
version: link:../customers
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
@ -517,8 +517,8 @@ importers:
specifier: ^2.3.4
version: 2.3.4
react-hook-form:
specifier: ^7.58.1
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-i18next:
specifier: ^15.5.1
version: 15.7.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@ -573,7 +573,7 @@ importers:
version: link:../core
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
@ -611,8 +611,8 @@ importers:
specifier: ^7.7.0
version: 7.7.0(react@19.2.0)(styled-components@6.1.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
react-hook-form:
specifier: ^7.58.1
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-i18next:
specifier: ^16.2.4
version: 16.2.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@ -1003,7 +1003,7 @@ importers:
version: 5.2.7
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
add:
specifier: ^2.0.6
version: 2.0.6
@ -1044,8 +1044,8 @@ importers:
specifier: 9.14.0
version: 9.14.0(react@19.2.0)
react-hook-form:
specifier: ^7.65.0
version: 7.66.0(react@19.2.0)
specifier: ^7.72.1
version: 7.72.1(react@19.2.0)
react-resizable-panels:
specifier: ^4.8.0
version: 4.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -5991,8 +5991,8 @@ packages:
peerDependencies:
react: '>=16.13.1'
react-hook-form@7.66.0:
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
react-hook-form@7.72.1:
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@ -7754,10 +7754,10 @@ snapshots:
- '@types/react'
- supports-color
'@hookform/resolvers@5.2.2(react-hook-form@7.66.0(react@19.2.0))':
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.0))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.66.0(react@19.2.0)
react-hook-form: 7.72.1(react@19.2.0)
'@humanfs/core@0.19.1': {}
@ -12249,7 +12249,7 @@ snapshots:
'@babel/runtime': 7.28.4
react: 19.2.0
react-hook-form@7.66.0(react@19.2.0):
react-hook-form@7.72.1(react@19.2.0):
dependencies:
react: 19.2.0