diff --git a/.vscode/settings.json b/.vscode/settings.json index 675787fa..51adb427 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts index 1e80bb75..1fd33a75 100644 --- a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts +++ b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts @@ -3,20 +3,23 @@ import { useEffect } from "react"; import { FieldValues, UseFormProps, UseFormReturn, useForm } from "react-hook-form"; import * as z4 from "zod/v4/core"; -type UseHookFormProps = UseFormProps & { - resolverSchema: z4.$ZodType; - initialValues: UseFormProps["defaultValues"]; +type UseHookFormProps = UseFormProps< + TFields, + TContext +> & { + resolverSchema: z4.$ZodType; + initialValues: UseFormProps["defaultValues"]; onDirtyChange?: (isDirty: boolean) => void; }; -export function useHookForm({ +export function useHookForm({ resolverSchema, initialValues, disabled, onDirtyChange, ...rest -}: UseHookFormProps): UseFormReturn { - const form = useForm({ +}: UseHookFormProps): UseFormReturn { + const form = useForm({ ...rest, resolver: zodResolver(resolverSchema), defaultValues: initialValues, diff --git a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx index a340fa3a..94301648 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx @@ -1,12 +1,14 @@ import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale"; -import type { +import type { CellKeyDownEvent, RowClickedEvent, ValueFormatterParams } from "ag-grid-community"; +import { + ColDef, + GridOptions, SizeColumnsToContentStrategy, SizeColumnsToFitGridStrategy, SizeColumnsToFitProvidedWidthStrategy, - ValueFormatterParams, } from "ag-grid-community"; -import { ColDef, GridOptions } from "ag-grid-community"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; + import { MoneyDTO } from "@erp/core"; import { formatDate, formatMoney } from "@erp/core/client"; @@ -110,6 +112,47 @@ export const CustomerInvoicesListGrid = () => { }, ]); + // Navegación centralizada (click/teclado) + const goToRow = useCallback( + (id: string, newTab = false) => { + const url = `/customer-invoices/${id}/edit`; + if (newTab) { + window.open(url, "_blank", "noopener,noreferrer"); + } else { + navigate(url); + } + }, + [navigate] + ); + + const onRowClicked = useCallback( + (e: RowClickedEvent) => { + if (!e.data) return; + // Soporta Ctrl/Cmd click para nueva pestaña + const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey); + goToRow(e.data.id, newTab); + }, + [goToRow] + ); + + const onCellKeyDown = useCallback( + (e: CellKeyDownEvent) => { + if (!e.data) return; + const key = e.event.key; + // Enter o Space disparan navegación + if (key === "Enter" || key === " ") { + e.event.preventDefault(); + goToRow(e.data.id); + } + // Ctrl/Cmd+Enter abre en nueva pestaña + if ((e.event.ctrlKey || e.event.metaKey) && key === "Enter") { + e.event.preventDefault(); + goToRow(e.data.id, true); + } + }, + [goToRow] + ); + const autoSizeStrategy = useMemo< | SizeColumnsToFitGridStrategy | SizeColumnsToFitProvidedWidthStrategy @@ -137,6 +180,18 @@ export const CustomerInvoicesListGrid = () => { paginationPageSize: 15, paginationPageSizeSelector: [10, 15, 20, 30, 50], localeText: AG_GRID_LOCALE_ES, + + // Evita conflictos con selección si la usas + suppressRowClickSelection: true, + // Clase visual de fila clickeable + getRowClass: () => "clickable-row", + // Accesibilidad con teclado + onCellKeyDown, + // Click en cualquier parte de la fila + onRowClicked, + // IDs estables (opcional pero recomendado) + getRowId: (params) => params.data.id, + }), [autoSizeStrategy, colDefs] ); diff --git a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx index f9d00128..95e14605 100644 --- a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx +++ b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx @@ -1,63 +1,64 @@ -import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components"; -import { FieldErrors, useFormContext } from "react-hook-form"; +import { FieldErrors, useFormContext, useWatch } from "react-hook-form"; import { FormDebug } from "@erp/core/components"; -import { CustomerModalSelectorField } from "@erp/customers/components"; -import { UserIcon } from "lucide-react"; import { useTranslation } from "../../i18n"; import { CustomerInvoiceFormData } from "../../schemas"; import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields"; import { InvoiceItems } from "./invoice-items-editor"; import { InvoiceTaxSummary } from "./invoice-tax-summary"; import { InvoiceTotals } from "./invoice-totals"; +import { InvoiceRecipient } from "./recipient"; interface CustomerInvoiceFormProps { formId: string; onSubmit: (data: CustomerInvoiceFormData) => void; onError: (errors: FieldErrors) => void; + className: string; } export const CustomerInvoiceEditForm = ({ formId, onSubmit, onError, + className, }: CustomerInvoiceFormProps) => { const { t } = useTranslation(); const form = useFormContext(); + const { defaultValues: initialValues } = form.formState; + initialValues.recip + + const prueba = useWatch({ + control: form.control, + }); + return (
-
- -
-
-
- +
+
+
+
+
+ +
-
-
- - {t("form_groups.customer.title")} - - {t("form_groups.customer.description")} - - - -
-
+
+ +
-
- -
+
+ +
-
- +
+ +
+
+ +
-
- -
-
+
); }; diff --git a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx index 44d5a139..3e9755cd 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx @@ -30,7 +30,7 @@ export const InvoiceBasicInfoFields = () => { {t("form_groups.basic_into.description")} - + { description={t("form_fields.invoice_number.description")} /> { { { /> { description={t("form_fields.description.description")} /> { const { t } = useTranslation(); const { control } = useFormContext(); + + const invoice = useWatch({ control }); const { fields: items, ...fieldActions } = useFieldArray({ diff --git a/modules/customer-invoices/src/web/components/editor/recipient/index.ts b/modules/customer-invoices/src/web/components/editor/recipient/index.ts new file mode 100644 index 00000000..ceee3729 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/recipient/index.ts @@ -0,0 +1,2 @@ +export * from "./invoice-recipient"; +export * from "./recipient-modal-selector-field"; diff --git a/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx b/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx new file mode 100644 index 00000000..8ff420b8 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx @@ -0,0 +1,34 @@ +import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { UserIcon } from "lucide-react"; +import { useTranslation } from "../../../i18n"; +import { CustomerInvoiceFormData } from "../../../schemas"; +import { RecipientModalSelectorField } from "./recipient-modal-selector-field"; + +export const InvoiceRecipient = () => { + const { t } = useTranslation(); + const { control } = useFormContext(); + + const recipient = useWatch({ + control, + name: "recipient", + defaultValue: "", + }); + + return ( +
+ + {t("form_groups.customer.title")} + + {t("form_groups.customer.description")} + + + +
+ ); +}; diff --git a/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx b/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx new file mode 100644 index 00000000..8f2b09f7 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx @@ -0,0 +1,45 @@ +import { CustomerModalSelector } from "@erp/customers/components"; +import { FormField, FormItem } from "@repo/shadcn-ui/components"; + +import { Control, FieldPath, FieldValues } from "react-hook-form"; + +type CustomerModalSelectorFieldProps = { + control: Control; + name: FieldPath; + disabled?: boolean; + required?: boolean; + readOnly?: boolean; + className?: string; +}; + +export function RecipientModalSelectorField({ + control, + name, + disabled = false, + required = false, + readOnly = false, + className, +}: CustomerModalSelectorFieldProps) { + const isDisabled = disabled; + const isReadOnly = readOnly && !disabled; + + return ( + { + const { name, value, onChange, onBlur, ref } = field; + //console.log({ name, value, onChange, onBlur, ref }); + return ( + + + + ); + }} + /> + ); +} diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 982d6094..6fc6ca23 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -15,7 +15,7 @@ const CustomerInvoiceAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate })) ); const CustomerInvoiceUpdate = lazy(() => - import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdate })) + import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdatePage })) ); export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => { diff --git a/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts index 45eabf9a..8cc28e8d 100644 --- a/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts +++ b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts @@ -2,7 +2,7 @@ import { useDataSource } from "@erp/core/hooks"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { CreateCustomerInvoiceRequestSchema } from "../../common"; -import { CustomerInvoiceData, CustomerInvoiceFormData } from "../schemas"; +import { CustomerInvoice, CustomerInvoiceFormData } from "../schemas"; type CreateCustomerInvoicePayload = { data: CustomerInvoiceFormData; @@ -13,7 +13,7 @@ export const useCreateCustomerInvoiceMutation = () => { const dataSource = useDataSource(); const schema = CreateCustomerInvoiceRequestSchema; - return useMutation({ + return useMutation({ mutationKey: ["customer-invoice:create"], mutationFn: async (payload) => { @@ -37,7 +37,7 @@ export const useCreateCustomerInvoiceMutation = () => { } const created = await dataSource.createOne("customer-invoices", newInvoiceData); - return created as CustomerInvoiceData; + return created as CustomerInvoice; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["customer-invoices"] }); diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoice-query.ts b/modules/customer-invoices/src/web/hooks/use-customer-invoice-query.ts index 408e85ee..7662284f 100644 --- a/modules/customer-invoices/src/web/hooks/use-customer-invoice-query.ts +++ b/modules/customer-invoices/src/web/hooks/use-customer-invoice-query.ts @@ -1,6 +1,6 @@ import { useDataSource } from "@erp/core/hooks"; import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; -import { CustomerInvoiceData } from "../schemas"; +import { CustomerInvoice } from "../schemas"; export const CUSTOMER_INVOICE_QUERY_KEY = (id: string): QueryKey => ["customer_invoice", id] as const; @@ -13,14 +13,14 @@ export function useCustomerInvoiceQuery(invoiceId?: string, options?: CustomerIn const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && !!invoiceId; - return useQuery({ + return useQuery({ queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"), queryFn: async (context) => { const { signal } = context; if (!invoiceId) { if (!invoiceId) throw new Error("invoiceId is required"); } - return await dataSource.getOne("customer-invoices", invoiceId, { + return await dataSource.getOne("customer-invoices", invoiceId, { signal, }); }, diff --git a/modules/customer-invoices/src/web/pages/create/create.tsx b/modules/customer-invoices/src/web/pages/create/create-customer-invoice-page.tsx similarity index 100% rename from modules/customer-invoices/src/web/pages/create/create.tsx rename to modules/customer-invoices/src/web/pages/create/create-customer-invoice-page.tsx diff --git a/modules/customer-invoices/src/web/pages/create/index.ts b/modules/customer-invoices/src/web/pages/create/index.ts index c6262006..e1ae53d8 100644 --- a/modules/customer-invoices/src/web/pages/create/index.ts +++ b/modules/customer-invoices/src/web/pages/create/index.ts @@ -1 +1 @@ -export * from "./create"; +export * from "./create-customer-invoice-page"; diff --git a/modules/customer-invoices/src/web/pages/update/customer-invoices-update.tsx b/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx similarity index 95% rename from modules/customer-invoices/src/web/pages/update/customer-invoices-update.tsx rename to modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx index 9d4d43ba..7f9ee9dc 100644 --- a/modules/customer-invoices/src/web/pages/update/customer-invoices-update.tsx +++ b/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx @@ -19,17 +19,18 @@ import { import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks"; import { useTranslation } from "../../i18n"; import { + CustomerInvoice, CustomerInvoiceFormData, CustomerInvoiceFormSchema, defaultCustomerInvoiceFormData, } from "../../schemas"; -export const CustomerInvoiceUpdate = () => { +export const CustomerInvoiceUpdatePage = () => { const invoiceId = useUrlParamId(); const { t } = useTranslation(); const navigate = useNavigate(); - // 1) Estado de carga del cliente (query) + // 1) Estado de carga de la factura (query) const { data: invoiceData, isLoading: isLoadingInvoice, @@ -46,7 +47,7 @@ export const CustomerInvoiceUpdate = () => { } = useUpdateCustomerInvoice(); // 3) Form hook - const form = useHookForm({ + const form = useHookForm({ resolverSchema: CustomerInvoiceFormSchema, initialValues: invoiceData ?? defaultCustomerInvoiceFormData, disabled: isUpdating, @@ -169,6 +170,8 @@ export const CustomerInvoiceUpdate = () => { formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit onSubmit={handleSubmit} onError={handleError} + className='max-w-full' + initialValues={invoiceData} /> diff --git a/modules/customer-invoices/src/web/pages/update/index.ts b/modules/customer-invoices/src/web/pages/update/index.ts index 828bf7f5..de0f684c 100644 --- a/modules/customer-invoices/src/web/pages/update/index.ts +++ b/modules/customer-invoices/src/web/pages/update/index.ts @@ -1 +1 @@ -export * from "./customer-invoices-update"; +export * from "./customer-invoices-update-page"; diff --git a/modules/customer-invoices/src/web/schemas/customer-invoices.api.schema.ts b/modules/customer-invoices/src/web/schemas/customer-invoices.api.schema.ts index ea7d432b..8cf77474 100644 --- a/modules/customer-invoices/src/web/schemas/customer-invoices.api.schema.ts +++ b/modules/customer-invoices/src/web/schemas/customer-invoices.api.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; +import { ArrayElement } from "@repo/rdx-utils"; import { CreateCustomerInvoiceRequestSchema, GetCustomerInvoiceByIdResponseSchema, @@ -7,12 +8,20 @@ import { UpdateCustomerInvoiceByIdRequestSchema, } from "../../common"; -export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema; -export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema; +// Esquemas (Zod) provenientes del servidor export const CustomerInvoiceSchema = GetCustomerInvoiceByIdResponseSchema.omit({ metadata: true, }); +export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema; +export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema; -export type CustomerInvoiceData = z.infer; +// Tipos (derivados de Zod o DTOs del backend) +export type CustomerInvoice = z.infer; +export type CustomerInvoiceCreateInput = z.infer; // Cuerpo para crear +export type CustomerInvoiceUpdateInput = z.infer; // Cuerpo para actualizar -export type CustomerInvoicesListData = ListCustomerInvoicesResponseDTO; +// Resultado de consulta con criteria (paginado, etc.) +export type CustomerInvoicesPage = ListCustomerInvoicesResponseDTO; + +// Ítem simplificado dentro del listado (no toda la entidad) +export type CustomerInvoiceSummary = Omit, "metadata">; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx index 10db8a9c..07045c4d 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx @@ -15,6 +15,8 @@ interface CustomerCardProps { onViewCustomer?: () => void; onChangeCustomer?: () => void; onAddNewCustomer?: () => void; + + className: string; } export const CustomerCard = ({ @@ -22,6 +24,7 @@ export const CustomerCard = ({ onViewCustomer, onChangeCustomer, onAddNewCustomer, + className, }: CustomerCardProps) => { const hasAddress = customer.street || @@ -32,7 +35,7 @@ export const CustomerCard = ({ customer.country; return ( -
+
{/* Avatar mejorado con gradiente sutil */}
@@ -97,7 +100,7 @@ export const CustomerCard = ({ className='flex-1 min-w-[140px] gap-2 bg-transparent' > - Cambiar cliente + Cambiar de cliente
-
+
); }; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx index 06778763..610146da 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx @@ -28,10 +28,11 @@ export function CustomerModalSelectorField({ control={control} name={name} render={({ field }) => { - console.log(field); + const { name, value, onChange, onBlur, ref } = field; + console.log({ name, value, onChange, onBlur, ref }); return ( - + ); }} diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx index 1b0b4ef1..c84f09bb 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx @@ -19,9 +19,16 @@ function useDebouncedValue(value: T, delay = 300) { interface CustomerModalSelectorProps { value?: string; onValueChange?: (id: string) => void; + initialCustomer?: CustomerSummary; + className: string; } -export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSelectorProps) => { +export const CustomerModalSelector = ({ + value, + onValueChange, + initialCustomer, + className, +}: CustomerModalSelectorProps) => { // UI state const [showSearch, setShowSearch] = useState(false); const [showForm, setShowForm] = useState(false); @@ -55,8 +62,7 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel // Sync con `value` useEffect(() => { - if (!value) return; - const found = customers.find((c) => c.id === value) ?? null; + const found = customers.find((c) => c.id === value) ?? initialCustomer; setSelected(found); }, [value, customers]); @@ -67,8 +73,7 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel ...newClient, }; setLocalCreated((prev) => [newCustomer, ...prev]); - setSelected(newCustomer); - onValueChange?.(newCustomer.id); + onValueChange?.(newCustomer.id); // <- ahora el "source of truth" es React Hook Form setShowForm(false); setShowSearch(false); }; @@ -77,9 +82,14 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel <>
{selected ? ( - setShowSearch(true)} /> + setShowSearch(true)} + /> ) : ( setShowSearch(true)} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)} /> diff --git a/modules/customers/src/web/components/customers-list-grid.tsx b/modules/customers/src/web/components/customers-list-grid.tsx index 2a7332ad..75471af8 100644 --- a/modules/customers/src/web/components/customers-list-grid.tsx +++ b/modules/customers/src/web/components/customers-list-grid.tsx @@ -1,5 +1,5 @@ import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale"; -import type { ValueFormatterParams } from "ag-grid-community"; +import type { CellKeyDownEvent, RowClickedEvent, ValueFormatterParams } from "ag-grid-community"; import { ColDef, GridOptions, @@ -7,7 +7,7 @@ import { SizeColumnsToFitGridStrategy, SizeColumnsToFitProvidedWidthStrategy, } from "ag-grid-community"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ErrorOverlay } from "@repo/rdx-ui/components"; import { Button } from "@repo/shadcn-ui/components"; @@ -35,7 +35,7 @@ export const CustomersListGrid = () => { }); // Column Definitions: Defines & controls grid columns. - const [colDefs] = useState([ + const [columnDefs] = useState([ { field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 }, { field: "tin", @@ -90,6 +90,47 @@ export const CustomersListGrid = () => { }, ]); + // Navegación centralizada (click/teclado) + const goToRow = useCallback( + (id: string, newTab = false) => { + const url = `/customers/${id}`; + if (newTab) { + window.open(url, "_blank", "noopener,noreferrer"); + } else { + navigate(url); + } + }, + [navigate] + ); + + const onRowClicked = useCallback( + (e: RowClickedEvent) => { + if (!e.data) return; + // Soporta Ctrl/Cmd click para nueva pestaña + const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey); + goToRow(e.data.id, newTab); + }, + [goToRow] + ); + + const onCellKeyDown = useCallback( + (e: CellKeyDownEvent) => { + if (!e.data) return; + const key = e.event.key; + // Enter o Space disparan navegación + if (key === "Enter" || key === " ") { + e.event.preventDefault(); + goToRow(e.data.id); + } + // Ctrl/Cmd+Enter abre en nueva pestaña + if ((e.event.ctrlKey || e.event.metaKey) && key === "Enter") { + e.event.preventDefault(); + goToRow(e.data.id, true); + } + }, + [goToRow] + ); + const autoSizeStrategy = useMemo< | SizeColumnsToFitGridStrategy | SizeColumnsToFitProvidedWidthStrategy @@ -104,7 +145,7 @@ export const CustomersListGrid = () => { const gridOptions: GridOptions = useMemo( () => ({ - columnDefs: colDefs, + columnDefs: columnDefs, autoSizeStrategy: autoSizeStrategy, defaultColDef: { editable: false, @@ -117,8 +158,19 @@ export const CustomersListGrid = () => { paginationPageSize: 15, paginationPageSizeSelector: [10, 15, 20, 30, 50], localeText: AG_GRID_LOCALE_ES, + + // Evita conflictos con selección si la usas + suppressRowClickSelection: true, + // Clase visual de fila clickeable + getRowClass: () => "clickable-row", + // Accesibilidad con teclado + onCellKeyDown, + // Click en cualquier parte de la fila + onRowClicked, + // IDs estables (opcional pero recomendado) + getRowId: (params) => params.data.id, }), - [autoSizeStrategy, colDefs] + [autoSizeStrategy, columnDefs, onCellKeyDown, onRowClicked] ); if (isLoadError) { diff --git a/modules/customers/src/web/components/editor/customer-edit-form.tsx b/modules/customers/src/web/components/editor/customer-edit-form.tsx index 7a94daa4..7dc5df23 100644 --- a/modules/customers/src/web/components/editor/customer-edit-form.tsx +++ b/modules/customers/src/web/components/editor/customer-edit-form.tsx @@ -5,7 +5,6 @@ import { CustomerFormData } from "../../schemas"; import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; import { CustomerAddressFields } from "./customer-address-fields"; import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; -import { CustomerContactFields } from "./customer-contact-fields"; interface CustomerFormProps { formId: string; @@ -25,7 +24,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProp
- +
diff --git a/modules/customers/src/web/customer-routes.tsx b/modules/customers/src/web/customer-routes.tsx index b83243cd..b6949bb8 100644 --- a/modules/customers/src/web/customer-routes.tsx +++ b/modules/customers/src/web/customer-routes.tsx @@ -1,7 +1,7 @@ import { ModuleClientParams } from "@erp/core/client"; import { lazy } from "react"; import { Outlet, RouteObject } from "react-router-dom"; -import { CustomerUpdate } from "./pages/update"; +import { CustomerUpdatePage } from "./pages/update"; // Lazy load components const CustomersLayout = lazy(() => @@ -9,8 +9,8 @@ const CustomersLayout = lazy(() => ); const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList }))); - -const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreate }))); +const CustomerView = lazy(() => import("./pages").then((m) => ({ default: m.CustomerViewPage }))); +const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage }))); export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { return [ @@ -25,7 +25,8 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { { path: "", index: true, element: }, // index { path: "list", element: }, { path: "create", element: }, - { path: ":id/edit", element: }, + { path: ":id", element: }, + { path: ":id/edit", element: }, // /*{ path: "create", element: }, diff --git a/modules/customers/src/web/pages/create/customer-create.tsx b/modules/customers/src/web/pages/create/customer-create-page.tsx similarity index 98% rename from modules/customers/src/web/pages/create/customer-create.tsx rename to modules/customers/src/web/pages/create/customer-create-page.tsx index c337137a..524cb291 100644 --- a/modules/customers/src/web/pages/create/customer-create.tsx +++ b/modules/customers/src/web/pages/create/customer-create-page.tsx @@ -9,7 +9,7 @@ import { useCreateCustomer } from "../../hooks"; import { useTranslation } from "../../i18n"; import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; -export const CustomerCreate = () => { +export const CustomerCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); diff --git a/modules/customers/src/web/pages/create/index.ts b/modules/customers/src/web/pages/create/index.ts index 67210436..375deba4 100644 --- a/modules/customers/src/web/pages/create/index.ts +++ b/modules/customers/src/web/pages/create/index.ts @@ -1 +1 @@ -export * from "./customer-create"; +export * from "./customer-create-page"; diff --git a/modules/customers/src/web/pages/index.ts b/modules/customers/src/web/pages/index.ts index 2f35f6f1..5402a730 100644 --- a/modules/customers/src/web/pages/index.ts +++ b/modules/customers/src/web/pages/index.ts @@ -1,2 +1,3 @@ export * from "./create"; export * from "./customer-list"; +export * from "./view"; diff --git a/modules/customers/src/web/pages/update/customer-update-modal.tsx b/modules/customers/src/web/pages/update/customer-update-modal.tsx new file mode 100644 index 00000000..5802b668 --- /dev/null +++ b/modules/customers/src/web/pages/update/customer-update-modal.tsx @@ -0,0 +1,168 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@repo/shadcn-ui/components"; +import { X } from "lucide-react"; + +import { FieldErrors, FormProvider } from "react-hook-form"; +import { CustomerAdditionalConfigFields } from "../../components/editor/customer-additional-config-fields"; +import { CustomerAddressFields } from "../../components/editor/customer-address-fields"; +import { CustomerBasicInfoFields } from "../../components/editor/customer-basic-info-fields"; + +import { useNavigate } from "react-router-dom"; + +import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client"; +import { UnsavedChangesProvider, useHookForm } from "@erp/core/hooks"; +import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; +import { CustomerEditorSkeleton } from "../../components"; +import { useCustomerQuery, useUpdateCustomer } from "../../hooks"; +import { useTranslation } from "../../i18n"; +import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; + +interface CustomerEditModalProps { + customerId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEditModalProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + // 1) Estado de carga del cliente (query) + const { + data: customerData, + isLoading: isLoadingCustomer, + isError: isLoadError, + error: loadError, + } = useCustomerQuery(customerId, { enabled: !!customerId }); + + // 2) Estado de actualización (mutación) + const { + mutate, + isPending: isUpdating, + isError: isUpdateError, + error: updateError, + } = useUpdateCustomer(); + + // 3) Form hook + const form = useHookForm({ + resolverSchema: CustomerFormSchema, + initialValues: customerData ?? defaultCustomerFormData, + disabled: isUpdating, + }); + + // 4) Submit con navegación condicionada por éxito + const handleSubmit = (formData: CustomerFormData) => { + const { dirtyFields } = form.formState; + + if (!formHasAnyDirty(dirtyFields)) { + showWarningToast("No hay cambios para guardar"); + return; + } + + const patchData = pickFormDirtyValues(formData, dirtyFields); + mutate( + { id: customerId!, data: patchData }, + { + onSuccess(data) { + showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg")); + + // 🔹 limpiar el form e isDirty pasa a false + form.reset(data); + }, + onError(error) { + showErrorToast(t("pages.update.errorTitle"), error.message); + }, + } + ); + }; + + const handleReset = () => form.reset(customerData ?? defaultCustomerFormData); + + const handleBack = () => { + navigate(-1); + }; + + const handleError = (errors: FieldErrors) => { + console.error("Errores en el formulario:", errors); + // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario + }; + + if (isLoadingCustomer || isLoadError) { + return ; + } + + return ( + + + + + +
+
+ Editar Cliente + + Modifica la información del cliente + +
+ +
+
+ + + + Información Básica + Dirección + Contacto + Preferencias + + +
+ + + + + + + + + + + + + + + +
+ +
+
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/modules/customers/src/web/pages/update/customer-update.tsx b/modules/customers/src/web/pages/update/customer-update-page.tsx similarity index 99% rename from modules/customers/src/web/pages/update/customer-update.tsx rename to modules/customers/src/web/pages/update/customer-update-page.tsx index c6deada4..0adadfdb 100644 --- a/modules/customers/src/web/pages/update/customer-update.tsx +++ b/modules/customers/src/web/pages/update/customer-update-page.tsx @@ -20,7 +20,7 @@ import { useCustomerQuery, useUpdateCustomer } from "../../hooks"; import { useTranslation } from "../../i18n"; import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; -export const CustomerUpdate = () => { +export const CustomerUpdatePage = () => { const customerId = useUrlParamId(); const { t } = useTranslation(); const navigate = useNavigate(); diff --git a/modules/customers/src/web/pages/update/index.ts b/modules/customers/src/web/pages/update/index.ts index 335de657..5f1b1cc5 100644 --- a/modules/customers/src/web/pages/update/index.ts +++ b/modules/customers/src/web/pages/update/index.ts @@ -1 +1,2 @@ -export * from "./customer-update"; +export * from "./customer-update-modal"; +export * from "./customer-update-page"; diff --git a/modules/customers/src/web/pages/view/customer-view-page.tsx b/modules/customers/src/web/pages/view/customer-view-page.tsx new file mode 100644 index 00000000..6e5d9bbe --- /dev/null +++ b/modules/customers/src/web/pages/view/customer-view-page.tsx @@ -0,0 +1,341 @@ +import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; +import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components"; +import { + Banknote, + Building2, + EditIcon, + FileText, + Globe, + Languages, + Mail, + MapPin, + MoreVertical, + Phone, + Smartphone, + User, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useUrlParamId } from "@erp/core/hooks"; +import { Badge } from "@repo/shadcn-ui/components"; +import { CustomerEditorSkeleton, ErrorAlert } from "../../components"; +import { useCustomerQuery } from "../../hooks"; +import { useTranslation } from "../../i18n"; + +export const CustomerViewPage = () => { + const customerId = useUrlParamId(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + // 1) Estado de carga del cliente (query) + const { + data: customer, + isLoading: isLoadingCustomer, + isError: isLoadError, + error: loadError, + } = useCustomerQuery(customerId, { enabled: !!customerId }); + + if (isLoadingCustomer) { + return ; + } + + if (isLoadError) { + return ( + <> + + + + +
+ +
+
+ + ); + } + + return ( + <> + + +
+ {/* Header */} +
+
+
+ {customer?.is_company ? ( + + ) : ( + + )} +
+
+

{customer?.name}

+
+ + {customer?.reference} + + {customer?.is_company ? "Empresa" : "Persona"} +
+
+
+
+ + +
+
+ + {/* Main Content Grid */} +
+ {/* Información Básica */} + + + + + Información Básica + + + +
+
Nombre
+
{customer?.name}
+
+
+
Referencia
+
+ {customer?.reference} +
+
+
+
Registro Legal
+
{customer?.legalRecord}
+
+
+
+ Impuestos por Defecto +
+
+ {customer?.defaultTax} +
+
+
+
+ + {/* Dirección */} + + + + + Dirección + + + +
+
Calle
+
+ {customer?.street1} + {customer?.street2 && ( + <> +
+ {customer?.street2} + + )} +
+
+
+
+
Ciudad
+
{customer?.city}
+
+
+
Código Postal
+
{customer?.postal_code}
+
+
+
+
+
Provincia
+
{customer?.province}
+
+
+
País
+
{customer?.country}
+
+
+
+
+ + {/* Información de Contacto */} + + + + + Información de Contacto + + + +
+ {/* Contacto Principal */} +
+

Contacto Principal

+ {customer?.email_primary && ( +
+ +
+
Email
+
+ {customer?.email_primary} +
+
+
+ )} + {customer?.mobile_primary && ( +
+ +
+
Móvil
+
+ {customer?.mobile_primary} +
+
+
+ )} + {customer?.phone_primary && ( +
+ +
+
Teléfono
+
+ {customer?.phone_primary} +
+
+
+ )} +
+ + {/* Contacto Secundario */} +
+

Contacto Secundario

+ {customer?.email_secondary && ( +
+ +
+
Email
+
+ {customer?.email_secondary} +
+
+
+ )} + {customer?.mobile_secondary && ( +
+ +
+
Móvil
+
+ {customer?.mobile_secondary} +
+
+
+ )} + {customer?.phone_secondary && ( +
+ +
+
Teléfono
+
+ {customer?.phone_secondary} +
+
+
+ )} +
+ + {/* Otros Contactos */} + {(customer?.website || customer?.fax) && ( +
+

Otros

+
+ {customer?.website && ( +
+ +
+
+ Sitio Web +
+
+ + {customer?.website} + +
+
+
+ )} + {customer?.fax && ( +
+ +
+
Fax
+
{customer?.fax}
+
+
+ )} +
+
+ )} +
+
+
+ + {/* Preferencias */} + + + + + Preferencias + + + +
+
+ +
+
+ Idioma Preferido +
+
{customer?.language_code}
+
+
+
+ +
+
+ Moneda Preferida +
+
{customer?.currency_code}
+
+
+
+
+
+
+
+
+ + ); +}; diff --git a/modules/customers/src/web/pages/view/index.ts b/modules/customers/src/web/pages/view/index.ts new file mode 100644 index 00000000..44ef4e26 --- /dev/null +++ b/modules/customers/src/web/pages/view/index.ts @@ -0,0 +1 @@ +export * from "./customer-view-page"; diff --git a/packages/rdx-ui/src/components/form/DatePickerInputField.tsx b/packages/rdx-ui/src/components/form/DatePickerInputField.tsx deleted file mode 100644 index f6add0c4..00000000 --- a/packages/rdx-ui/src/components/form/DatePickerInputField.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Calendar, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - Popover, - PopoverContent, - PopoverTrigger, -} from "@repo/shadcn-ui/components"; -import { CalendarIcon, LockIcon, XIcon } from "lucide-react"; - -import { cn } from "@repo/shadcn-ui/lib/utils"; -import { format, isValid, parse } from "date-fns"; -import { useState } from "react"; -import { Control, FieldPath, FieldValues } from "react-hook-form"; -import { useTranslation } from "../../locales/i18n.ts"; - -type DatePickerInputFieldProps = { - control: Control; - name: FieldPath; - label: string; - placeholder?: string; - description?: string; - disabled?: boolean; - required?: boolean; - readOnly?: boolean; - className?: string; - formatDateFn?: (iso: string) => string; - parseDateFormat?: string; // e.g. "dd/MM/yyyy" -}; - -export function DatePickerInputField({ - control, - name, - label, - placeholder, - description, - disabled = false, - required = false, - readOnly = false, - className, - formatDateFn = (iso) => format(new Date(iso), "dd/MM/yyyy"), - parseDateFormat = "dd/MM/yyyy", -}: DatePickerInputFieldProps) { - const { t } = useTranslation(); - const isDisabled = disabled; - const isReadOnly = readOnly && !disabled; - - return ( - { - const [inputValue, setInputValue] = useState( - field.value ? formatDateFn(field.value) : "" - ); - const [inputError, setInputError] = useState(null); - - const handleInputChange = (value: string) => { - setInputValue(value); - setInputError(null); - }; - - const validateAndSetDate = () => { - const trimmed = inputValue.trim(); - if (!trimmed) { - field.onChange(undefined); - setInputError(null); - return; - } - - const parsed = parse(trimmed, parseDateFormat, new Date()); - if (isValid(parsed)) { - field.onChange(parsed.toISOString()); - setInputError(null); - } else { - setInputError(t("common.invalidDate") || "Fecha no válida"); - } - }; - - return ( - -
- {label} - {required && {t("common.required")}} -
- - - - -
- handleInputChange(e.target.value)} - onBlur={validateAndSetDate} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - validateAndSetDate(); - } - }} - readOnly={isReadOnly} - disabled={isDisabled} - className={cn( - "w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:font-normal placeholder:italic", - isDisabled && "bg-muted text-muted-foreground cursor-not-allowed", - isReadOnly && "bg-muted text-foreground cursor-default", - !isDisabled && !isReadOnly && "bg-white text-foreground", - inputError && "border-destructive ring-destructive" - )} - placeholder={placeholder} - /> -
- {!isReadOnly && !required && inputValue && ( - - )} - {isReadOnly ? ( - - ) : ( - - )} -
-
-
-
- - {!isDisabled && !isReadOnly && ( - - { - if (date) { - const iso = date.toISOString(); - field.onChange(iso); - setInputValue(formatDateFn(iso)); - setInputError(null); - } else { - field.onChange(undefined); - setInputValue(""); - } - }} - initialFocus - /> - - )} -
- - {isReadOnly && ( -

- {t("common.readOnly") || "Solo lectura"} -

- )} - - - {description || "\u00A0"} - - - -
- ); - }} - /> - ); -} diff --git a/packages/rdx-ui/src/components/form/TextAreaField.tsx b/packages/rdx-ui/src/components/form/TextAreaField.tsx index 9efb4ddf..4e5140aa 100644 --- a/packages/rdx-ui/src/components/form/TextAreaField.tsx +++ b/packages/rdx-ui/src/components/form/TextAreaField.tsx @@ -11,10 +11,11 @@ import { } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import { Control, FieldPath, FieldValues } from "react-hook-form"; +import { Control, FieldPath, FieldValues, useController } from "react-hook-form"; import { useTranslation } from "../../locales/i18n.ts"; +import { CommonInputProps } from "./types.js"; -type TextAreaFieldProps = { +type TextAreaFieldProps = CommonInputProps & { control: Control; name: FieldPath; label?: string; @@ -24,6 +25,9 @@ type TextAreaFieldProps = { required?: boolean; readOnly?: boolean; className?: string; + + /** Contador de caracteres (si usas maxLength) */ + showCounter?: boolean; }; export function TextAreaField({ @@ -36,9 +40,17 @@ export function TextAreaField({ required = false, readOnly = false, className, + showCounter = false, + maxLength, }: TextAreaFieldProps) { const { t } = useTranslation(); const isDisabled = disabled || readOnly; + const { field, fieldState } = useController({ control, name }); + + const describedById = description ? `${name}-desc` : undefined; + const errorId = fieldState.error ? `${name}-err` : undefined; + + const valueLength = (field.value?.length ?? 0) as number; return ( ({ {label && (
- + {label} {required && ( {t("common.required")} )}
+ {/* Punto “unsaved” */} + {fieldState.isDirty && ( + {t("common.modified")} + )}
)} @@ -63,14 +82,27 @@ export function TextAreaField({ disabled={isDisabled} placeholder={placeholder} className={"placeholder:font-normal placeholder:italic bg-background"} + maxLength={maxLength} {...field} /> - - {description || "\u00A0"} - - +
+ + {description || "\u00A0"} + + + {showCounter && typeof maxLength === "number" && ( +

+ {valueLength} / {maxLength} +

+ )} +
+ + )} /> diff --git a/packages/rdx-ui/src/components/form/TextField.tsx b/packages/rdx-ui/src/components/form/TextField.tsx index 97d6c94b..09c58adb 100644 --- a/packages/rdx-ui/src/components/form/TextField.tsx +++ b/packages/rdx-ui/src/components/form/TextField.tsx @@ -12,6 +12,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils"; import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form"; import { useTranslation } from "../../locales/i18n.ts"; +import { CommonInputProps } from "./types.js"; /** * @@ -73,11 +74,6 @@ import { useTranslation } from "../../locales/i18n.ts"; /** Presets de comportamiento */ type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password"; -type CommonInputProps = Omit< - React.InputHTMLAttributes, - "name" | "value" | "onChange" | "onBlur" | "ref" | "type" ->; - type Normalizer = (value: string) => string; type TextFieldProps = CommonInputProps & { @@ -346,6 +342,7 @@ export function TextField({ )}
)} +
{/* Prefix clicable (si tiene onClick) */} diff --git a/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-comp.tsx b/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-comp.tsx new file mode 100644 index 00000000..574cff59 --- /dev/null +++ b/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-comp.tsx @@ -0,0 +1,268 @@ +import { + Button, + Calendar, + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, + Popover, + PopoverContent, + PopoverTrigger, +} from "@repo/shadcn-ui/components"; +import { CalendarIcon, LockIcon, XIcon } from "lucide-react"; + +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { format, isValid, parse } from "date-fns"; +import { useEffect, useState } from "react"; +import { FieldValues } from "react-hook-form"; +import { useTranslation } from "../../../locales/i18n.ts"; + +import { ControllerFieldState, ControllerRenderProps, UseFormStateReturn } from "react-hook-form"; + +export type SUICalendarProps = Omit, "select" | "onSelect"> + +type DatePickerInputCompProps = SUICalendarProps & { + field: ControllerRenderProps; + fieldState: ControllerFieldState; + formState: UseFormStateReturn; + + displayDateFormat: string; // e.g. "dd/MM/yyyy" + parseDateFormat: string; // e.g. "yyyy/MM/dd" + + label: string; + placeholder?: string; + description?: string; + disabled?: boolean; + required?: boolean; + readOnly?: boolean; + + className?: string; +}; + +export function DatePickerInputComp({ + field, + fieldState, + formState, + + parseDateFormat, + displayDateFormat, + + label, + placeholder, + description, + disabled = false, + required = false, + readOnly = false, + className, + ...calendarProps +}: DatePickerInputCompProps) { + const { t } = useTranslation(); + const isDisabled = disabled; + const isReadOnly = readOnly && !disabled; + + const describedById = description ? `${field.name}-desc` : undefined; + const errorId = fieldState.error ? `${field.name}-err` : undefined; + + const [open, setOpen] = useState(false); // Popover + const [displayValue, setDisplayValue] = useState(""); + + // Sync cuando RHF actualiza el valor externamente + useEffect(() => { + if (field.value) { + // field.value ya viene en formato parseDateFormat + console.log(field.value, parseDateFormat); + const parsed = parse(field.value, parseDateFormat, new Date()); + console.log("parsed =>", parsed); + if (isValid(parsed)) { + setDisplayValue(format(parsed, displayDateFormat)); + } + } else { + setDisplayValue(""); + } + }, [field.value, parseDateFormat, displayDateFormat]); + + const [inputError, setInputError] = useState(null); + + const handleDisplayValueChange = (value: string) => { + console.log("handleDisplayValueChange => ", value) + setDisplayValue(value); + setInputError(null); + }; + + const handleClearDate = () => { + handleDisplayValueChange(""); + } + + const validateAndSetDate = () => { + const trimmed = displayValue.trim(); + if (!trimmed) { + field.onChange(""); // guardar vacío en el form + setInputError(null); + return; + } + + const parsed = parse(trimmed, displayDateFormat, new Date()); + if (isValid(parsed)) { + // Guardar en form como string con parseDateFormat + const newDateStr = format(parsed, parseDateFormat); + field.onChange(newDateStr); + // Asegurar displayValue consistente + handleDisplayValueChange(newDateStr); + } else { + setInputError(t("components.date_picker_input_field.invalid_date")); + } + }; + + return ( + + {label && ( +
+
+ + {label} + + {required && {t("common.required")}} +
+ {/* Punto “unsaved” */} + {fieldState.isDirty && ( + {t("common.modified")} + )} +
+ )} + + + + +
+ handleDisplayValueChange(e.target.value)} + onBlur={() => { if (!open) validateAndSetDate(); }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + validateAndSetDate(); + } + }} + readOnly={isReadOnly} + disabled={isDisabled} + className={cn( + "w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:font-normal placeholder:italic", + isDisabled && "bg-muted text-muted-foreground cursor-not-allowed", + isReadOnly && "bg-muted text-foreground cursor-default", + !isDisabled && !isReadOnly && "bg-white text-foreground", + inputError && "border-destructive ring-destructive" + )} + placeholder={placeholder} + /> +
+ {!isReadOnly && !required && displayValue && ( + + )} + {isReadOnly ? ( + + ) : ( + + )} +
+
+
+
+ + {!isDisabled && !isReadOnly && ( + + + + {label} + {description || "\u00A0"} + + + + + + { + const newDateStr = date ? format(date, parseDateFormat) : ""; + field.onChange(newDateStr); + handleDisplayValueChange(newDateStr); + setOpen(false); + }} + initialFocus + /> + + + + + + + )} +
+ + {isReadOnly && ( +

+ {t("common.read_only") || "Solo lectura"} +

+ )} + +
+ + {description || "\u00A0"} + +
+ + +
+ ); +} diff --git a/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-field.tsx b/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-field.tsx new file mode 100644 index 00000000..bf50a1e5 --- /dev/null +++ b/packages/rdx-ui/src/components/form/date-picker-input-field/date-picker-input-field.tsx @@ -0,0 +1,43 @@ +import { FormField } from "@repo/shadcn-ui/components"; + +import { Control, FieldPath, FieldValues } from "react-hook-form"; +import { DatePickerInputComp, SUICalendarProps } from "./date-picker-input-comp.tsx"; + +type DatePickerInputFieldProps = SUICalendarProps & { + control: Control; + name: FieldPath; + label: string; + placeholder?: string; + description?: string; + disabled?: boolean; + required?: boolean; + readOnly?: boolean; + className?: string; + displayDateFormat?: string; // e.g. "dd/MM/yyyy" + parseDateFormat?: string; // e.g. "yyyy-MM-dd" +}; + +export function DatePickerInputField({ + control, + name, + displayDateFormat = "dd-MM-yyyy", + parseDateFormat = "yyyy-MM-dd", + ...props +}: DatePickerInputFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/packages/rdx-ui/src/components/form/date-picker-input-field/index.ts b/packages/rdx-ui/src/components/form/date-picker-input-field/index.ts new file mode 100644 index 00000000..8794a61a --- /dev/null +++ b/packages/rdx-ui/src/components/form/date-picker-input-field/index.ts @@ -0,0 +1 @@ +export * from "./date-picker-input-field.tsx"; diff --git a/packages/rdx-ui/src/components/form/index.tsx b/packages/rdx-ui/src/components/form/index.tsx index 41e71abb..d3303b3a 100644 --- a/packages/rdx-ui/src/components/form/index.tsx +++ b/packages/rdx-ui/src/components/form/index.tsx @@ -1,5 +1,5 @@ +export * from "./date-picker-input-field/index.ts"; export * from "./DatePickerField.tsx"; -export * from "./DatePickerInputField.tsx"; export * from "./fieldset.tsx"; export * from "./multi-select-field.tsx"; export * from "./SelectField.tsx"; diff --git a/packages/rdx-ui/src/components/form/types.d.ts b/packages/rdx-ui/src/components/form/types.d.ts new file mode 100644 index 00000000..a4298b0e --- /dev/null +++ b/packages/rdx-ui/src/components/form/types.d.ts @@ -0,0 +1,4 @@ +export type CommonInputProps = Omit< + React.InputHTMLAttributes, + "name" | "value" | "onChange" | "onBlur" | "ref" | "type" +>; diff --git a/packages/rdx-ui/src/locales/en.json b/packages/rdx-ui/src/locales/en.json index 369f0711..2d41b43a 100644 --- a/packages/rdx-ui/src/locales/en.json +++ b/packages/rdx-ui/src/locales/en.json @@ -1,10 +1,10 @@ { "common": { "actions": "Actions", - "invalid_date": "Invalid date", "required": "•", "modified": "modified", - "search": "Search" + "search": "Search", + "read_only": "Read only" }, "components": { "loading_indicator": { @@ -22,6 +22,12 @@ "no_results": "No results found.", "select_options": "Select options", "select_all": "Select all" + }, + "date_picker_input_field": { + "invalid_date": "Invalid date", + "clear_date": "Clear date", + "today": "Today", + "close": "Close" } } }