Facturas de cliente
This commit is contained in:
parent
58eede59af
commit
bb1ce13fee
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -39,7 +39,7 @@
|
|||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
},
|
},
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
|||||||
@ -3,20 +3,23 @@ import { useEffect } from "react";
|
|||||||
import { FieldValues, UseFormProps, UseFormReturn, useForm } from "react-hook-form";
|
import { FieldValues, UseFormProps, UseFormReturn, useForm } from "react-hook-form";
|
||||||
import * as z4 from "zod/v4/core";
|
import * as z4 from "zod/v4/core";
|
||||||
|
|
||||||
type UseHookFormProps<T extends FieldValues = FieldValues> = UseFormProps<T> & {
|
type UseHookFormProps<TFields extends FieldValues = FieldValues, TContext = any> = UseFormProps<
|
||||||
resolverSchema: z4.$ZodType<T, any>;
|
TFields,
|
||||||
initialValues: UseFormProps<T>["defaultValues"];
|
TContext
|
||||||
|
> & {
|
||||||
|
resolverSchema: z4.$ZodType<TFields, any>;
|
||||||
|
initialValues: UseFormProps<TFields>["defaultValues"];
|
||||||
onDirtyChange?: (isDirty: boolean) => void;
|
onDirtyChange?: (isDirty: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useHookForm<T extends FieldValues = FieldValues>({
|
export function useHookForm<TFields extends FieldValues = FieldValues, TContext = any>({
|
||||||
resolverSchema,
|
resolverSchema,
|
||||||
initialValues,
|
initialValues,
|
||||||
disabled,
|
disabled,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
...rest
|
...rest
|
||||||
}: UseHookFormProps<T>): UseFormReturn<T> {
|
}: UseHookFormProps<TFields, TContext>): UseFormReturn<TFields> {
|
||||||
const form = useForm<T>({
|
const form = useForm<TFields, TContext>({
|
||||||
...rest,
|
...rest,
|
||||||
resolver: zodResolver(resolverSchema),
|
resolver: zodResolver(resolverSchema),
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
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,
|
SizeColumnsToContentStrategy,
|
||||||
SizeColumnsToFitGridStrategy,
|
SizeColumnsToFitGridStrategy,
|
||||||
SizeColumnsToFitProvidedWidthStrategy,
|
SizeColumnsToFitProvidedWidthStrategy,
|
||||||
ValueFormatterParams,
|
|
||||||
} from "ag-grid-community";
|
} from "ag-grid-community";
|
||||||
import { ColDef, GridOptions } from "ag-grid-community";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { MoneyDTO } from "@erp/core";
|
import { MoneyDTO } from "@erp/core";
|
||||||
import { formatDate, formatMoney } from "@erp/core/client";
|
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<unknown>) => {
|
||||||
|
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<unknown>) => {
|
||||||
|
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<
|
const autoSizeStrategy = useMemo<
|
||||||
| SizeColumnsToFitGridStrategy
|
| SizeColumnsToFitGridStrategy
|
||||||
| SizeColumnsToFitProvidedWidthStrategy
|
| SizeColumnsToFitProvidedWidthStrategy
|
||||||
@ -137,6 +180,18 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
paginationPageSize: 15,
|
paginationPageSize: 15,
|
||||||
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
||||||
localeText: AG_GRID_LOCALE_ES,
|
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, colDefs]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,63 +1,64 @@
|
|||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { FieldErrors, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { FieldErrors, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { FormDebug } from "@erp/core/components";
|
import { FormDebug } from "@erp/core/components";
|
||||||
import { CustomerModalSelectorField } from "@erp/customers/components";
|
|
||||||
import { UserIcon } from "lucide-react";
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerInvoiceFormData } from "../../schemas";
|
import { CustomerInvoiceFormData } from "../../schemas";
|
||||||
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
||||||
import { InvoiceItems } from "./invoice-items-editor";
|
import { InvoiceItems } from "./invoice-items-editor";
|
||||||
import { InvoiceTaxSummary } from "./invoice-tax-summary";
|
import { InvoiceTaxSummary } from "./invoice-tax-summary";
|
||||||
import { InvoiceTotals } from "./invoice-totals";
|
import { InvoiceTotals } from "./invoice-totals";
|
||||||
|
import { InvoiceRecipient } from "./recipient";
|
||||||
|
|
||||||
interface CustomerInvoiceFormProps {
|
interface CustomerInvoiceFormProps {
|
||||||
formId: string;
|
formId: string;
|
||||||
onSubmit: (data: CustomerInvoiceFormData) => void;
|
onSubmit: (data: CustomerInvoiceFormData) => void;
|
||||||
onError: (errors: FieldErrors<CustomerInvoiceFormData>) => void;
|
onError: (errors: FieldErrors<CustomerInvoiceFormData>) => void;
|
||||||
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerInvoiceEditForm = ({
|
export const CustomerInvoiceEditForm = ({
|
||||||
formId,
|
formId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onError,
|
onError,
|
||||||
|
className,
|
||||||
}: CustomerInvoiceFormProps) => {
|
}: CustomerInvoiceFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = useFormContext<CustomerInvoiceFormData>();
|
const form = useFormContext<CustomerInvoiceFormData>();
|
||||||
|
|
||||||
|
const { defaultValues: initialValues } = form.formState;
|
||||||
|
initialValues.recip
|
||||||
|
|
||||||
|
const prueba = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
||||||
<div className='w-full'>
|
<section className={className}>
|
||||||
<FormDebug />
|
<div className='w-full'>
|
||||||
</div>
|
<FormDebug />
|
||||||
<div className='mx-auto grid w-full grid-cols-1 gap-6 lg:grid-flow-col-dense lg:grid-cols-2 items-stretch'>
|
|
||||||
<div className='lg:col-start-1 space-y-6'>
|
|
||||||
<InvoiceBasicInfoFields />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='mx-auto grid w-full grid-cols-1 gap-6 lg:grid-flow-col-dense lg:grid-cols-2 items-stretch'>
|
||||||
|
<div className='lg:col-start-1 space-y-6'>
|
||||||
|
<InvoiceBasicInfoFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='space-y-6 '>
|
<div className='space-y-6 '>
|
||||||
<Fieldset>
|
<InvoiceRecipient />
|
||||||
<Legend className='flex items-center gap-2 text-foreground'>
|
</div>
|
||||||
<UserIcon className='size-5' /> {t("form_groups.customer.title")}
|
|
||||||
</Legend>
|
|
||||||
<Description>{t("form_groups.customer.description")}</Description>
|
|
||||||
<FieldGroup>
|
|
||||||
<CustomerModalSelectorField control={form.control} name='customer_id' />
|
|
||||||
</FieldGroup>
|
|
||||||
</Fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='lg:col-start-1 lg:col-span-2 space-y-6'>
|
<div className='lg:col-start-1 lg:col-span-2 space-y-6'>
|
||||||
<InvoiceItems />
|
<InvoiceItems />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='lg:col-start-1 space-y-6'>
|
<div className='lg:col-start-1 space-y-6'>
|
||||||
<InvoiceTaxSummary />
|
<InvoiceTaxSummary />
|
||||||
|
</div>
|
||||||
|
<div className='space-y-6 '>
|
||||||
|
<InvoiceTotals />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-6 '>
|
</section>
|
||||||
<InvoiceTotals />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
</Legend>
|
</Legend>
|
||||||
|
|
||||||
<Description>{t("form_groups.basic_into.description")}</Description>
|
<Description>{t("form_groups.basic_into.description")}</Description>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-3'>
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name='invoice_number'
|
name='invoice_number'
|
||||||
@ -40,6 +40,7 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
description={t("form_fields.invoice_number.description")}
|
description={t("form_fields.invoice_number.description")}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
typePreset='text'
|
||||||
control={control}
|
control={control}
|
||||||
name='series'
|
name='series'
|
||||||
label={t("form_fields.series.label")}
|
label={t("form_fields.series.label")}
|
||||||
@ -51,6 +52,7 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
<DatePickerInputField
|
<DatePickerInputField
|
||||||
control={control}
|
control={control}
|
||||||
name='invoice_date'
|
name='invoice_date'
|
||||||
|
numberOfMonths={2}
|
||||||
required
|
required
|
||||||
label={t("form_fields.invoice_date.label")}
|
label={t("form_fields.invoice_date.label")}
|
||||||
placeholder={t("form_fields.invoice_date.placeholder")}
|
placeholder={t("form_fields.invoice_date.placeholder")}
|
||||||
@ -61,6 +63,7 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
<Field className='lg:col-span-2 lg:col-start-1 2xl:col-auto'>
|
<Field className='lg:col-span-2 lg:col-start-1 2xl:col-auto'>
|
||||||
<DatePickerInputField
|
<DatePickerInputField
|
||||||
control={control}
|
control={control}
|
||||||
|
numberOfMonths={2}
|
||||||
name='operation_date'
|
name='operation_date'
|
||||||
label={t("form_fields.operation_date.label")}
|
label={t("form_fields.operation_date.label")}
|
||||||
placeholder={t("form_fields.operation_date.placeholder")}
|
placeholder={t("form_fields.operation_date.placeholder")}
|
||||||
@ -68,6 +71,8 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<TextField
|
<TextField
|
||||||
|
typePreset='text'
|
||||||
|
maxLength={256}
|
||||||
className='lg:col-span-2'
|
className='lg:col-span-2'
|
||||||
control={control}
|
control={control}
|
||||||
name='description'
|
name='description'
|
||||||
@ -76,7 +81,8 @@ export const InvoiceBasicInfoFields = () => {
|
|||||||
description={t("form_fields.description.description")}
|
description={t("form_fields.description.description")}
|
||||||
/>
|
/>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
className='lg:col-span-2'
|
maxLength={1024}
|
||||||
|
className='lg:col-span-full'
|
||||||
control={control}
|
control={control}
|
||||||
name='notes'
|
name='notes'
|
||||||
label={t("form_fields.notes.label")}
|
label={t("form_fields.notes.label")}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export const InvoiceItems = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<CustomerInvoiceFormData>();
|
const { control } = useFormContext<CustomerInvoiceFormData>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const invoice = useWatch({ control });
|
const invoice = useWatch({ control });
|
||||||
|
|
||||||
const { fields: items, ...fieldActions } = useFieldArray({
|
const { fields: items, ...fieldActions } = useFieldArray({
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./invoice-recipient";
|
||||||
|
export * from "./recipient-modal-selector-field";
|
||||||
@ -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<CustomerInvoiceFormData>();
|
||||||
|
|
||||||
|
const recipient = useWatch({
|
||||||
|
control,
|
||||||
|
name: "recipient",
|
||||||
|
defaultValue: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fieldset>
|
||||||
|
<Legend className='flex items-center gap-2 text-foreground'>
|
||||||
|
<UserIcon className='size-5' /> {t("form_groups.customer.title")}
|
||||||
|
</Legend>
|
||||||
|
<Description>{t("form_groups.customer.description")}</Description>
|
||||||
|
<FieldGroup>
|
||||||
|
<RecipientModalSelectorField
|
||||||
|
control={control}
|
||||||
|
name='customer_id'
|
||||||
|
initialRecipient='recipient'
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</Fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<TFormValues extends FieldValues> = {
|
||||||
|
control: Control<TFormValues>;
|
||||||
|
name: FieldPath<TFormValues>;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecipientModalSelectorField<TFormValues extends FieldValues>({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
}: CustomerModalSelectorFieldProps<TFormValues>) {
|
||||||
|
const isDisabled = disabled;
|
||||||
|
const isReadOnly = readOnly && !disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const { name, value, onChange, onBlur, ref } = field;
|
||||||
|
//console.log({ name, value, onChange, onBlur, ref });
|
||||||
|
return (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<CustomerModalSelector
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className='bg-fuchsia-200'
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ const CustomerInvoiceAdd = lazy(() =>
|
|||||||
import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
|
import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
|
||||||
);
|
);
|
||||||
const CustomerInvoiceUpdate = lazy(() =>
|
const CustomerInvoiceUpdate = lazy(() =>
|
||||||
import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdate }))
|
import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdatePage }))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
|
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
|
|||||||
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
|
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CreateCustomerInvoiceRequestSchema } from "../../common";
|
import { CreateCustomerInvoiceRequestSchema } from "../../common";
|
||||||
import { CustomerInvoiceData, CustomerInvoiceFormData } from "../schemas";
|
import { CustomerInvoice, CustomerInvoiceFormData } from "../schemas";
|
||||||
|
|
||||||
type CreateCustomerInvoicePayload = {
|
type CreateCustomerInvoicePayload = {
|
||||||
data: CustomerInvoiceFormData;
|
data: CustomerInvoiceFormData;
|
||||||
@ -13,7 +13,7 @@ export const useCreateCustomerInvoiceMutation = () => {
|
|||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const schema = CreateCustomerInvoiceRequestSchema;
|
const schema = CreateCustomerInvoiceRequestSchema;
|
||||||
|
|
||||||
return useMutation<CustomerInvoiceData, DefaultError, CreateCustomerInvoicePayload>({
|
return useMutation<CustomerInvoice, DefaultError, CreateCustomerInvoicePayload>({
|
||||||
mutationKey: ["customer-invoice:create"],
|
mutationKey: ["customer-invoice:create"],
|
||||||
|
|
||||||
mutationFn: async (payload) => {
|
mutationFn: async (payload) => {
|
||||||
@ -37,7 +37,7 @@ export const useCreateCustomerInvoiceMutation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await dataSource.createOne("customer-invoices", newInvoiceData);
|
const created = await dataSource.createOne("customer-invoices", newInvoiceData);
|
||||||
return created as CustomerInvoiceData;
|
return created as CustomerInvoice;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["customer-invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["customer-invoices"] });
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
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 =>
|
export const CUSTOMER_INVOICE_QUERY_KEY = (id: string): QueryKey =>
|
||||||
["customer_invoice", id] as const;
|
["customer_invoice", id] as const;
|
||||||
@ -13,14 +13,14 @@ export function useCustomerInvoiceQuery(invoiceId?: string, options?: CustomerIn
|
|||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && !!invoiceId;
|
const enabled = (options?.enabled ?? true) && !!invoiceId;
|
||||||
|
|
||||||
return useQuery<CustomerInvoiceData, DefaultError>({
|
return useQuery<CustomerInvoice, DefaultError>({
|
||||||
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
|
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
|
||||||
queryFn: async (context) => {
|
queryFn: async (context) => {
|
||||||
const { signal } = context;
|
const { signal } = context;
|
||||||
if (!invoiceId) {
|
if (!invoiceId) {
|
||||||
if (!invoiceId) throw new Error("invoiceId is required");
|
if (!invoiceId) throw new Error("invoiceId is required");
|
||||||
}
|
}
|
||||||
return await dataSource.getOne<CustomerInvoiceData>("customer-invoices", invoiceId, {
|
return await dataSource.getOne<CustomerInvoice>("customer-invoices", invoiceId, {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export * from "./create";
|
export * from "./create-customer-invoice-page";
|
||||||
|
|||||||
@ -19,17 +19,18 @@ import {
|
|||||||
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
|
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
|
CustomerInvoice,
|
||||||
CustomerInvoiceFormData,
|
CustomerInvoiceFormData,
|
||||||
CustomerInvoiceFormSchema,
|
CustomerInvoiceFormSchema,
|
||||||
defaultCustomerInvoiceFormData,
|
defaultCustomerInvoiceFormData,
|
||||||
} from "../../schemas";
|
} from "../../schemas";
|
||||||
|
|
||||||
export const CustomerInvoiceUpdate = () => {
|
export const CustomerInvoiceUpdatePage = () => {
|
||||||
const invoiceId = useUrlParamId();
|
const invoiceId = useUrlParamId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 1) Estado de carga del cliente (query)
|
// 1) Estado de carga de la factura (query)
|
||||||
const {
|
const {
|
||||||
data: invoiceData,
|
data: invoiceData,
|
||||||
isLoading: isLoadingInvoice,
|
isLoading: isLoadingInvoice,
|
||||||
@ -46,7 +47,7 @@ export const CustomerInvoiceUpdate = () => {
|
|||||||
} = useUpdateCustomerInvoice();
|
} = useUpdateCustomerInvoice();
|
||||||
|
|
||||||
// 3) Form hook
|
// 3) Form hook
|
||||||
const form = useHookForm<CustomerInvoiceFormData>({
|
const form = useHookForm<CustomerInvoiceFormData, CustomerInvoice>({
|
||||||
resolverSchema: CustomerInvoiceFormSchema,
|
resolverSchema: CustomerInvoiceFormSchema,
|
||||||
initialValues: invoiceData ?? defaultCustomerInvoiceFormData,
|
initialValues: invoiceData ?? defaultCustomerInvoiceFormData,
|
||||||
disabled: isUpdating,
|
disabled: isUpdating,
|
||||||
@ -169,6 +170,8 @@ export const CustomerInvoiceUpdate = () => {
|
|||||||
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
|
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
className='max-w-full'
|
||||||
|
initialValues={invoiceData}
|
||||||
/>
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
@ -1 +1 @@
|
|||||||
export * from "./customer-invoices-update";
|
export * from "./customer-invoices-update-page";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { ArrayElement } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
CreateCustomerInvoiceRequestSchema,
|
CreateCustomerInvoiceRequestSchema,
|
||||||
GetCustomerInvoiceByIdResponseSchema,
|
GetCustomerInvoiceByIdResponseSchema,
|
||||||
@ -7,12 +8,20 @@ import {
|
|||||||
UpdateCustomerInvoiceByIdRequestSchema,
|
UpdateCustomerInvoiceByIdRequestSchema,
|
||||||
} from "../../common";
|
} from "../../common";
|
||||||
|
|
||||||
export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema;
|
// Esquemas (Zod) provenientes del servidor
|
||||||
export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema;
|
|
||||||
export const CustomerInvoiceSchema = GetCustomerInvoiceByIdResponseSchema.omit({
|
export const CustomerInvoiceSchema = GetCustomerInvoiceByIdResponseSchema.omit({
|
||||||
metadata: true,
|
metadata: true,
|
||||||
});
|
});
|
||||||
|
export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema;
|
||||||
|
export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema;
|
||||||
|
|
||||||
export type CustomerInvoiceData = z.infer<typeof CustomerInvoiceSchema>;
|
// Tipos (derivados de Zod o DTOs del backend)
|
||||||
|
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>;
|
||||||
|
export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear
|
||||||
|
export type CustomerInvoiceUpdateInput = z.infer<typeof CustomerInvoiceUpdateSchema>; // 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<ArrayElement<CustomerInvoicesPage["items"]>, "metadata">;
|
||||||
|
|||||||
@ -15,6 +15,8 @@ interface CustomerCardProps {
|
|||||||
onViewCustomer?: () => void;
|
onViewCustomer?: () => void;
|
||||||
onChangeCustomer?: () => void;
|
onChangeCustomer?: () => void;
|
||||||
onAddNewCustomer?: () => void;
|
onAddNewCustomer?: () => void;
|
||||||
|
|
||||||
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerCard = ({
|
export const CustomerCard = ({
|
||||||
@ -22,6 +24,7 @@ export const CustomerCard = ({
|
|||||||
onViewCustomer,
|
onViewCustomer,
|
||||||
onChangeCustomer,
|
onChangeCustomer,
|
||||||
onAddNewCustomer,
|
onAddNewCustomer,
|
||||||
|
className,
|
||||||
}: CustomerCardProps) => {
|
}: CustomerCardProps) => {
|
||||||
const hasAddress =
|
const hasAddress =
|
||||||
customer.street ||
|
customer.street ||
|
||||||
@ -32,7 +35,7 @@ export const CustomerCard = ({
|
|||||||
customer.country;
|
customer.country;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div className={className}>
|
||||||
<div className='flex items-start gap-4'>
|
<div className='flex items-start gap-4'>
|
||||||
{/* Avatar mejorado con gradiente sutil */}
|
{/* Avatar mejorado con gradiente sutil */}
|
||||||
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'>
|
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'>
|
||||||
@ -97,7 +100,7 @@ export const CustomerCard = ({
|
|||||||
className='flex-1 min-w-[140px] gap-2 bg-transparent'
|
className='flex-1 min-w-[140px] gap-2 bg-transparent'
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className='h-4 w-4' />
|
<RefreshCwIcon className='h-4 w-4' />
|
||||||
Cambiar cliente
|
Cambiar de cliente
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
@ -109,6 +112,6 @@ export const CustomerCard = ({
|
|||||||
Nuevo cliente
|
Nuevo cliente
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -28,10 +28,11 @@ export function CustomerModalSelectorField<TFormValues extends FieldValues>({
|
|||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
console.log(field);
|
const { name, value, onChange, onBlur, ref } = field;
|
||||||
|
console.log({ name, value, onChange, onBlur, ref });
|
||||||
return (
|
return (
|
||||||
<FormItem className={className}>
|
<FormItem className={className}>
|
||||||
<CustomerModalSelector />
|
<CustomerModalSelector value={value} onValueChange={onChange} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -19,9 +19,16 @@ function useDebouncedValue<T>(value: T, delay = 300) {
|
|||||||
interface CustomerModalSelectorProps {
|
interface CustomerModalSelectorProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onValueChange?: (id: string) => void;
|
onValueChange?: (id: string) => void;
|
||||||
|
initialCustomer?: CustomerSummary;
|
||||||
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSelectorProps) => {
|
export const CustomerModalSelector = ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
initialCustomer,
|
||||||
|
className,
|
||||||
|
}: CustomerModalSelectorProps) => {
|
||||||
// UI state
|
// UI state
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -55,8 +62,7 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel
|
|||||||
|
|
||||||
// Sync con `value`
|
// Sync con `value`
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) return;
|
const found = customers.find((c) => c.id === value) ?? initialCustomer;
|
||||||
const found = customers.find((c) => c.id === value) ?? null;
|
|
||||||
setSelected(found);
|
setSelected(found);
|
||||||
}, [value, customers]);
|
}, [value, customers]);
|
||||||
|
|
||||||
@ -67,8 +73,7 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel
|
|||||||
...newClient,
|
...newClient,
|
||||||
};
|
};
|
||||||
setLocalCreated((prev) => [newCustomer, ...prev]);
|
setLocalCreated((prev) => [newCustomer, ...prev]);
|
||||||
setSelected(newCustomer);
|
onValueChange?.(newCustomer.id); // <- ahora el "source of truth" es React Hook Form
|
||||||
onValueChange?.(newCustomer.id);
|
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setShowSearch(false);
|
setShowSearch(false);
|
||||||
};
|
};
|
||||||
@ -77,9 +82,14 @@ export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSel
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<CustomerCard customer={selected} onChangeCustomer={() => setShowSearch(true)} />
|
<CustomerCard
|
||||||
|
className={className}
|
||||||
|
customer={selected}
|
||||||
|
onChangeCustomer={() => setShowSearch(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CustomerEmptyCard
|
<CustomerEmptyCard
|
||||||
|
className={className}
|
||||||
onClick={() => setShowSearch(true)}
|
onClick={() => setShowSearch(true)}
|
||||||
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)}
|
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
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 {
|
import {
|
||||||
ColDef,
|
ColDef,
|
||||||
GridOptions,
|
GridOptions,
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
SizeColumnsToFitGridStrategy,
|
SizeColumnsToFitGridStrategy,
|
||||||
SizeColumnsToFitProvidedWidthStrategy,
|
SizeColumnsToFitProvidedWidthStrategy,
|
||||||
} from "ag-grid-community";
|
} from "ag-grid-community";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { ErrorOverlay } from "@repo/rdx-ui/components";
|
import { ErrorOverlay } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
@ -35,7 +35,7 @@ export const CustomersListGrid = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Column Definitions: Defines & controls grid columns.
|
// Column Definitions: Defines & controls grid columns.
|
||||||
const [colDefs] = useState<ColDef[]>([
|
const [columnDefs] = useState<ColDef[]>([
|
||||||
{ field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 },
|
{ field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 },
|
||||||
{
|
{
|
||||||
field: "tin",
|
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<unknown>) => {
|
||||||
|
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<unknown>) => {
|
||||||
|
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<
|
const autoSizeStrategy = useMemo<
|
||||||
| SizeColumnsToFitGridStrategy
|
| SizeColumnsToFitGridStrategy
|
||||||
| SizeColumnsToFitProvidedWidthStrategy
|
| SizeColumnsToFitProvidedWidthStrategy
|
||||||
@ -104,7 +145,7 @@ export const CustomersListGrid = () => {
|
|||||||
|
|
||||||
const gridOptions: GridOptions = useMemo(
|
const gridOptions: GridOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
columnDefs: colDefs,
|
columnDefs: columnDefs,
|
||||||
autoSizeStrategy: autoSizeStrategy,
|
autoSizeStrategy: autoSizeStrategy,
|
||||||
defaultColDef: {
|
defaultColDef: {
|
||||||
editable: false,
|
editable: false,
|
||||||
@ -117,8 +158,19 @@ export const CustomersListGrid = () => {
|
|||||||
paginationPageSize: 15,
|
paginationPageSize: 15,
|
||||||
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
||||||
localeText: AG_GRID_LOCALE_ES,
|
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) {
|
if (isLoadError) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { CustomerFormData } from "../../schemas";
|
|||||||
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
||||||
import { CustomerAddressFields } from "./customer-address-fields";
|
import { CustomerAddressFields } from "./customer-address-fields";
|
||||||
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
||||||
import { CustomerContactFields } from "./customer-contact-fields";
|
|
||||||
|
|
||||||
interface CustomerFormProps {
|
interface CustomerFormProps {
|
||||||
formId: string;
|
formId: string;
|
||||||
@ -25,7 +24,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProp
|
|||||||
<div className='w-full xl:grow space-y-6'>
|
<div className='w-full xl:grow space-y-6'>
|
||||||
<CustomerBasicInfoFields />
|
<CustomerBasicInfoFields />
|
||||||
<CustomerAddressFields />
|
<CustomerAddressFields />
|
||||||
<CustomerContactFields />
|
<CustomerAddressFields />
|
||||||
<CustomerAdditionalConfigFields />
|
<CustomerAdditionalConfigFields />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ModuleClientParams } from "@erp/core/client";
|
import { ModuleClientParams } from "@erp/core/client";
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Outlet, RouteObject } from "react-router-dom";
|
import { Outlet, RouteObject } from "react-router-dom";
|
||||||
import { CustomerUpdate } from "./pages/update";
|
import { CustomerUpdatePage } from "./pages/update";
|
||||||
|
|
||||||
// Lazy load components
|
// Lazy load components
|
||||||
const CustomersLayout = lazy(() =>
|
const CustomersLayout = lazy(() =>
|
||||||
@ -9,8 +9,8 @@ const CustomersLayout = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList })));
|
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList })));
|
||||||
|
const CustomerView = lazy(() => import("./pages").then((m) => ({ default: m.CustomerViewPage })));
|
||||||
const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreate })));
|
const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
|
||||||
|
|
||||||
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||||
return [
|
return [
|
||||||
@ -25,7 +25,8 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
|||||||
{ path: "", index: true, element: <CustomersList /> }, // index
|
{ path: "", index: true, element: <CustomersList /> }, // index
|
||||||
{ path: "list", element: <CustomersList /> },
|
{ path: "list", element: <CustomersList /> },
|
||||||
{ path: "create", element: <CustomerAdd /> },
|
{ path: "create", element: <CustomerAdd /> },
|
||||||
{ path: ":id/edit", element: <CustomerUpdate /> },
|
{ path: ":id", element: <CustomerView /> },
|
||||||
|
{ path: ":id/edit", element: <CustomerUpdatePage /> },
|
||||||
|
|
||||||
//
|
//
|
||||||
/*{ path: "create", element: <CustomersList /> },
|
/*{ path: "create", element: <CustomersList /> },
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useCreateCustomer } from "../../hooks";
|
|||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
||||||
|
|
||||||
export const CustomerCreate = () => {
|
export const CustomerCreatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -1 +1 @@
|
|||||||
export * from "./customer-create";
|
export * from "./customer-create-page";
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./create";
|
export * from "./create";
|
||||||
export * from "./customer-list";
|
export * from "./customer-list";
|
||||||
|
export * from "./view";
|
||||||
|
|||||||
168
modules/customers/src/web/pages/update/customer-update-modal.tsx
Normal file
168
modules/customers/src/web/pages/update/customer-update-modal.tsx
Normal file
@ -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<CustomerFormData>({
|
||||||
|
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<CustomerFormData>) => {
|
||||||
|
console.error("Errores en el formulario:", errors);
|
||||||
|
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingCustomer || isLoadError) {
|
||||||
|
return <CustomerEditorSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className='max-h-[90vh] max-w-3xl overflow-hidden p-0'>
|
||||||
|
<DialogHeader className='border-b px-6 py-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className='text-xl'>Editar Cliente</DialogTitle>
|
||||||
|
<DialogDescription className='mt-1'>
|
||||||
|
Modifica la información del cliente
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className='h-8 w-8'
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs defaultValue='basic' className='flex h-full flex-col'>
|
||||||
|
<TabsList className='mx-6 mt-4 grid w-auto grid-cols-4 gap-2'>
|
||||||
|
<TabsTrigger value='basic'>Información Básica</TabsTrigger>
|
||||||
|
<TabsTrigger value='address'>Dirección</TabsTrigger>
|
||||||
|
<TabsTrigger value='contact'>Contacto</TabsTrigger>
|
||||||
|
<TabsTrigger value='preferences'>Preferencias</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||||
|
<TabsContent value='basic' className='mt-0'>
|
||||||
|
<CustomerBasicInfoFields />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='address' className='mt-0'>
|
||||||
|
<CustomerAddressFields />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='contact' className='mt-0'>
|
||||||
|
<CustomerAddressFields />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='preferences' className='mt-0'>
|
||||||
|
<CustomerAdditionalConfigFields />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-t px-6 py-4'>
|
||||||
|
<div className='flex justify-end gap-3'>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Guardar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</FormProvider>
|
||||||
|
</UnsavedChangesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
|
|||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
||||||
|
|
||||||
export const CustomerUpdate = () => {
|
export const CustomerUpdatePage = () => {
|
||||||
const customerId = useUrlParamId();
|
const customerId = useUrlParamId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from "./customer-update";
|
export * from "./customer-update-modal";
|
||||||
|
export * from "./customer-update-page";
|
||||||
|
|||||||
341
modules/customers/src/web/pages/view/customer-view-page.tsx
Normal file
341
modules/customers/src/web/pages/view/customer-view-page.tsx
Normal file
@ -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 <CustomerEditorSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumb />
|
||||||
|
<AppContent>
|
||||||
|
<ErrorAlert
|
||||||
|
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
|
||||||
|
message={
|
||||||
|
(loadError as Error)?.message ??
|
||||||
|
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-end'>
|
||||||
|
<BackHistoryButton />
|
||||||
|
</div>
|
||||||
|
</AppContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumb />
|
||||||
|
<AppContent>
|
||||||
|
<div className='space-y-6 max-w-4xl'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-start justify-between'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10'>
|
||||||
|
{customer?.is_company ? (
|
||||||
|
<Building2 className='h-8 w-8 text-primary' />
|
||||||
|
) : (
|
||||||
|
<User className='h-8 w-8 text-primary' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-bold text-foreground'>{customer?.name}</h1>
|
||||||
|
<div className='mt-2 flex items-center gap-3'>
|
||||||
|
<Badge variant='secondary' className='font-mono'>
|
||||||
|
{customer?.reference}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>{customer?.is_company ? "Empresa" : "Persona"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button variant='outline' size='icon' onClick={() => navigate("/customers/list")}>
|
||||||
|
<MoreVertical className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<EditIcon className='mr-2 h-4 w-4' />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className='grid gap-6 md:grid-cols-2'>
|
||||||
|
{/* Información Básica */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<FileText className='h-5 w-5 text-primary' />
|
||||||
|
Información Básica
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Nombre</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Referencia</dt>
|
||||||
|
<dd className='mt-1 font-mono text-base text-foreground'>
|
||||||
|
{customer?.reference}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Registro Legal</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.legalRecord}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>
|
||||||
|
Impuestos por Defecto
|
||||||
|
</dt>
|
||||||
|
<dd className='mt-1'>
|
||||||
|
<Badge className='bg-blue-600 hover:bg-blue-700'>{customer?.defaultTax}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dirección */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<MapPin className='h-5 w-5 text-primary' />
|
||||||
|
Dirección
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Calle</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.street1}
|
||||||
|
{customer?.street2 && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{customer?.street2}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Ciudad</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.city}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Código Postal</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.postal_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Provincia</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.province}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>País</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.country}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Información de Contacto */}
|
||||||
|
<Card className='md:col-span-2'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<Mail className='h-5 w-5 text-primary' />
|
||||||
|
Información de Contacto
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='grid gap-6 md:grid-cols-2'>
|
||||||
|
{/* Contacto Principal */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='font-semibold text-foreground'>Contacto Principal</h3>
|
||||||
|
{customer?.email_primary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Mail className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Email</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.email_primary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer?.mobile_primary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Smartphone className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Móvil</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.mobile_primary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer?.phone_primary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Phone className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Teléfono</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.phone_primary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contacto Secundario */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='font-semibold text-foreground'>Contacto Secundario</h3>
|
||||||
|
{customer?.email_secondary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Mail className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Email</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.email_secondary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer?.mobile_secondary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Smartphone className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Móvil</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.mobile_secondary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer?.phone_secondary && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Phone className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Teléfono</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>
|
||||||
|
{customer?.phone_secondary}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Otros Contactos */}
|
||||||
|
{(customer?.website || customer?.fax) && (
|
||||||
|
<div className='space-y-4 md:col-span-2'>
|
||||||
|
<h3 className='font-semibold text-foreground'>Otros</h3>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
{customer?.website && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Globe className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>
|
||||||
|
Sitio Web
|
||||||
|
</dt>
|
||||||
|
<dd className='mt-1 text-base text-primary hover:underline'>
|
||||||
|
<a
|
||||||
|
href={customer?.website}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{customer?.website}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer?.fax && (
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Phone className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>Fax</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.fax}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preferencias */}
|
||||||
|
<Card className='md:col-span-2'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-lg'>
|
||||||
|
<Languages className='h-5 w-5 text-primary' />
|
||||||
|
Preferencias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='grid gap-6 md:grid-cols-2'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Languages className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>
|
||||||
|
Idioma Preferido
|
||||||
|
</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.language_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<Banknote className='mt-0.5 h-4 w-4 text-muted-foreground' />
|
||||||
|
<div className='flex-1'>
|
||||||
|
<dt className='text-sm font-medium text-muted-foreground'>
|
||||||
|
Moneda Preferida
|
||||||
|
</dt>
|
||||||
|
<dd className='mt-1 text-base text-foreground'>{customer?.currency_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
modules/customers/src/web/pages/view/index.ts
Normal file
1
modules/customers/src/web/pages/view/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-view-page";
|
||||||
@ -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<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;
|
|
||||||
parseDateFormat?: string; // e.g. "dd/MM/yyyy"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DatePickerInputField<TFormValues extends FieldValues>({
|
|
||||||
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<TFormValues>) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isDisabled = disabled;
|
|
||||||
const isReadOnly = readOnly && !disabled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name={name}
|
|
||||||
render={({ field }) => {
|
|
||||||
const [inputValue, setInputValue] = useState<string>(
|
|
||||||
field.value ? formatDateFn(field.value) : ""
|
|
||||||
);
|
|
||||||
const [inputError, setInputError] = useState<string | null>(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 (
|
|
||||||
<FormItem className={cn("space-y-0", className)}>
|
|
||||||
<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>
|
|
||||||
<div className='relative'>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => 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}
|
|
||||||
/>
|
|
||||||
<div className='absolute inset-y-0 right-2 flex items-center gap-2 pr-1'>
|
|
||||||
{!isReadOnly && !required && inputValue && (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={() => {
|
|
||||||
setInputValue("");
|
|
||||||
field.onChange(undefined);
|
|
||||||
setInputError(null);
|
|
||||||
}}
|
|
||||||
aria-label={t("common.clearDate") || "Limpiar fecha"}
|
|
||||||
className='text-muted-foreground hover:text-foreground focus:outline-none'
|
|
||||||
>
|
|
||||||
<XIcon className='size-4 hover:text-destructive' />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isReadOnly ? (
|
|
||||||
<LockIcon className='size-4 text-muted-foreground' />
|
|
||||||
) : (
|
|
||||||
<CalendarIcon className='size-4 text-muted-foreground hover:text-primary hover:cursor-pointer' />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
{!isDisabled && !isReadOnly && (
|
|
||||||
<PopoverContent className='w-auto p-0'>
|
|
||||||
<Calendar
|
|
||||||
mode='single'
|
|
||||||
selected={field.value ? new Date(field.value) : undefined}
|
|
||||||
onSelect={(date) => {
|
|
||||||
if (date) {
|
|
||||||
const iso = date.toISOString();
|
|
||||||
field.onChange(iso);
|
|
||||||
setInputValue(formatDateFn(iso));
|
|
||||||
setInputError(null);
|
|
||||||
} else {
|
|
||||||
field.onChange(undefined);
|
|
||||||
setInputValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{isReadOnly && (
|
|
||||||
<p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
|
|
||||||
<LockIcon className='w-3 h-3' /> {t("common.readOnly") || "Solo lectura"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormDescription className={cn("text-xs truncate", !description && "invisible")}>
|
|
||||||
{description || "\u00A0"}
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -11,10 +11,11 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
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 { useTranslation } from "../../locales/i18n.ts";
|
||||||
|
import { CommonInputProps } from "./types.js";
|
||||||
|
|
||||||
type TextAreaFieldProps<TFormValues extends FieldValues> = {
|
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -24,6 +25,9 @@ type TextAreaFieldProps<TFormValues extends FieldValues> = {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
|
/** Contador de caracteres (si usas maxLength) */
|
||||||
|
showCounter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextAreaField<TFormValues extends FieldValues>({
|
export function TextAreaField<TFormValues extends FieldValues>({
|
||||||
@ -36,9 +40,17 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
|||||||
required = false,
|
required = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
|
showCounter = false,
|
||||||
|
maxLength,
|
||||||
}: TextAreaFieldProps<TFormValues>) {
|
}: TextAreaFieldProps<TFormValues>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDisabled = disabled || readOnly;
|
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 (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
@ -49,13 +61,20 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
|||||||
{label && (
|
{label && (
|
||||||
<div className='mb-1 flex justify-between gap-2'>
|
<div className='mb-1 flex justify-between gap-2'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<FormLabel htmlFor={name} className='m-0'>
|
<FormLabel
|
||||||
|
htmlFor={name}
|
||||||
|
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
{required && (
|
{required && (
|
||||||
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Punto “unsaved” */}
|
||||||
|
{fieldState.isDirty && (
|
||||||
|
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@ -63,14 +82,27 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={"placeholder:font-normal placeholder:italic bg-background"}
|
className={"placeholder:font-normal placeholder:italic bg-background"}
|
||||||
|
maxLength={maxLength}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription className={cn("text-xs truncate", !description && "invisible")}>
|
<div className='mt-1 flex items-start justify-between'>
|
||||||
{description || "\u00A0"}
|
<FormDescription
|
||||||
</FormDescription>
|
id={describedById}
|
||||||
<FormMessage />
|
className={cn("text-xs truncate", !description && "invisible")}
|
||||||
|
>
|
||||||
|
{description || "\u00A0"}
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
{showCounter && typeof maxLength === "number" && (
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
{valueLength} / {maxLength}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage id={errorId} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
|
|||||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||||
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form";
|
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form";
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
import { useTranslation } from "../../locales/i18n.ts";
|
||||||
|
import { CommonInputProps } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -73,11 +74,6 @@ import { useTranslation } from "../../locales/i18n.ts";
|
|||||||
/** Presets de comportamiento */
|
/** Presets de comportamiento */
|
||||||
type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password";
|
type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password";
|
||||||
|
|
||||||
type CommonInputProps = Omit<
|
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type Normalizer = (value: string) => string;
|
type Normalizer = (value: string) => string;
|
||||||
|
|
||||||
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||||
@ -346,6 +342,7 @@ export function TextField<TFormValues extends FieldValues>({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className={cn("relative")}>
|
<div className={cn("relative")}>
|
||||||
{/* Prefix clicable (si tiene onClick) */}
|
{/* Prefix clicable (si tiene onClick) */}
|
||||||
|
|||||||
@ -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<React.ComponentProps<
|
||||||
|
typeof Calendar>, "select" | "onSelect">
|
||||||
|
|
||||||
|
type DatePickerInputCompProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
||||||
|
field: ControllerRenderProps<TFormValues>;
|
||||||
|
fieldState: ControllerFieldState;
|
||||||
|
formState: UseFormStateReturn<TFormValues>;
|
||||||
|
|
||||||
|
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
||||||
|
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
||||||
|
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||||
|
field,
|
||||||
|
fieldState,
|
||||||
|
formState,
|
||||||
|
|
||||||
|
parseDateFormat,
|
||||||
|
displayDateFormat,
|
||||||
|
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
...calendarProps
|
||||||
|
}: DatePickerInputCompProps<TFormValues>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isDisabled = disabled;
|
||||||
|
const isReadOnly = readOnly && !disabled;
|
||||||
|
|
||||||
|
const describedById = description ? `${field.name}-desc` : undefined;
|
||||||
|
const errorId = fieldState.error ? `${field.name}-err` : undefined;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false); // Popover
|
||||||
|
const [displayValue, setDisplayValue] = useState<string>("");
|
||||||
|
|
||||||
|
// Sync cuando RHF actualiza el valor externamente
|
||||||
|
useEffect(() => {
|
||||||
|
if (field.value) {
|
||||||
|
// field.value ya viene en formato parseDateFormat
|
||||||
|
console.log(field.value, parseDateFormat);
|
||||||
|
const parsed = parse(field.value, parseDateFormat, new Date());
|
||||||
|
console.log("parsed =>", parsed);
|
||||||
|
if (isValid(parsed)) {
|
||||||
|
setDisplayValue(format(parsed, displayDateFormat));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDisplayValue("");
|
||||||
|
}
|
||||||
|
}, [field.value, parseDateFormat, displayDateFormat]);
|
||||||
|
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDisplayValueChange = (value: string) => {
|
||||||
|
console.log("handleDisplayValueChange => ", value)
|
||||||
|
setDisplayValue(value);
|
||||||
|
setInputError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearDate = () => {
|
||||||
|
handleDisplayValueChange("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateAndSetDate = () => {
|
||||||
|
const trimmed = displayValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
field.onChange(""); // guardar vacío en el form
|
||||||
|
setInputError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parse(trimmed, displayDateFormat, new Date());
|
||||||
|
if (isValid(parsed)) {
|
||||||
|
// Guardar en form como string con parseDateFormat
|
||||||
|
const newDateStr = format(parsed, parseDateFormat);
|
||||||
|
field.onChange(newDateStr);
|
||||||
|
// Asegurar displayValue consistente
|
||||||
|
handleDisplayValueChange(newDateStr);
|
||||||
|
} else {
|
||||||
|
setInputError(t("components.date_picker_input_field.invalid_date"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className={cn("space-y-0", className)}>
|
||||||
|
{label && (
|
||||||
|
<div className='mb-1 flex justify-between gap-2'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<FormLabel
|
||||||
|
htmlFor={field.name}
|
||||||
|
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
|
||||||
|
</div>
|
||||||
|
{/* Punto “unsaved” */}
|
||||||
|
{fieldState.isDirty && (
|
||||||
|
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
||||||
|
onBlur={() => { if (!open) validateAndSetDate(); }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
validateAndSetDate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:font-normal placeholder:italic",
|
||||||
|
isDisabled && "bg-muted text-muted-foreground cursor-not-allowed",
|
||||||
|
isReadOnly && "bg-muted text-foreground cursor-default",
|
||||||
|
!isDisabled && !isReadOnly && "bg-white text-foreground",
|
||||||
|
inputError && "border-destructive ring-destructive"
|
||||||
|
)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-y-0 right-2 flex items-center gap-2 pr-1'>
|
||||||
|
{!isReadOnly && !required && displayValue && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
field.onChange(""); // limpiar valor real en el form
|
||||||
|
setDisplayValue(""); // limpiar input visible
|
||||||
|
setInputError(null); // limpiar error
|
||||||
|
}} aria-label={t("common.clear_date")}
|
||||||
|
className='text-muted-foreground hover:text-foreground focus:outline-none'
|
||||||
|
>
|
||||||
|
<XIcon className='size-4 hover:text-destructive' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isReadOnly ? (
|
||||||
|
<LockIcon className='size-4 text-muted-foreground' />
|
||||||
|
) : (
|
||||||
|
<CalendarIcon className='size-4 text-muted-foreground hover:text-primary hover:cursor-pointer' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
{!isDisabled && !isReadOnly && (
|
||||||
|
<PopoverContent className='w-auto p-0'>
|
||||||
|
<Card className='border-none shadow-none'>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle>{label}</CardTitle>
|
||||||
|
<CardDescription>{description || "\u00A0"}</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const today = format(new Date(), parseDateFormat);
|
||||||
|
field.onChange(today);
|
||||||
|
handleDisplayValueChange(today);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("components.date_picker_input_field.today")}
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Calendar
|
||||||
|
defaultMonth={field.value ? new Date(field.value) : undefined}
|
||||||
|
{...calendarProps}
|
||||||
|
mode='single'
|
||||||
|
selected={field.value ? new Date(field.value) : undefined}
|
||||||
|
onSelect={(date) => {
|
||||||
|
const newDateStr = date ? format(date, parseDateFormat) : "";
|
||||||
|
field.onChange(newDateStr);
|
||||||
|
handleDisplayValueChange(newDateStr);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className='mx-auto'>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("components.date_picker_input_field.close")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</PopoverContent>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
|
||||||
|
<LockIcon className='w-3 h-3' /> {t("common.read_only") || "Solo lectura"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='mt-1 flex items-start justify-between'>
|
||||||
|
<FormDescription
|
||||||
|
id={describedById}
|
||||||
|
className={cn("text-xs truncate", !description && "invisible")}
|
||||||
|
>
|
||||||
|
{description || "\u00A0"}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage id={errorId} />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<TFormValues extends FieldValues> = SUICalendarProps & {
|
||||||
|
control: Control<TFormValues>;
|
||||||
|
name: FieldPath<TFormValues>;
|
||||||
|
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<TFormValues extends FieldValues>({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
displayDateFormat = "dd-MM-yyyy",
|
||||||
|
parseDateFormat = "yyyy-MM-dd",
|
||||||
|
...props
|
||||||
|
}: DatePickerInputFieldProps<TFormValues>) {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, fieldState, formState }) => (
|
||||||
|
<DatePickerInputComp
|
||||||
|
field={field}
|
||||||
|
fieldState={fieldState}
|
||||||
|
formState={formState}
|
||||||
|
displayDateFormat={displayDateFormat}
|
||||||
|
parseDateFormat={parseDateFormat}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./date-picker-input-field.tsx";
|
||||||
@ -1,5 +1,5 @@
|
|||||||
|
export * from "./date-picker-input-field/index.ts";
|
||||||
export * from "./DatePickerField.tsx";
|
export * from "./DatePickerField.tsx";
|
||||||
export * from "./DatePickerInputField.tsx";
|
|
||||||
export * from "./fieldset.tsx";
|
export * from "./fieldset.tsx";
|
||||||
export * from "./multi-select-field.tsx";
|
export * from "./multi-select-field.tsx";
|
||||||
export * from "./SelectField.tsx";
|
export * from "./SelectField.tsx";
|
||||||
|
|||||||
4
packages/rdx-ui/src/components/form/types.d.ts
vendored
Normal file
4
packages/rdx-ui/src/components/form/types.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type CommonInputProps = Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
||||||
|
>;
|
||||||
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"invalid_date": "Invalid date",
|
|
||||||
"required": "•",
|
"required": "•",
|
||||||
"modified": "modified",
|
"modified": "modified",
|
||||||
"search": "Search"
|
"search": "Search",
|
||||||
|
"read_only": "Read only"
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"loading_indicator": {
|
"loading_indicator": {
|
||||||
@ -22,6 +22,12 @@
|
|||||||
"no_results": "No results found.",
|
"no_results": "No results found.",
|
||||||
"select_options": "Select options",
|
"select_options": "Select options",
|
||||||
"select_all": "Select all"
|
"select_all": "Select all"
|
||||||
|
},
|
||||||
|
"date_picker_input_field": {
|
||||||
|
"invalid_date": "Invalid date",
|
||||||
|
"clear_date": "Clear date",
|
||||||
|
"today": "Today",
|
||||||
|
"close": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user