This commit is contained in:
David Arranz 2025-09-23 18:38:20 +02:00
parent 01ca0bd5dc
commit c8e3191d05
15 changed files with 176 additions and 100 deletions

View File

@ -9,11 +9,11 @@ import {
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants"; import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas"; import { CreateCustomerFormData } from "../../schemas";
export const CustomerAdditionalConfigFields = () => { export const CustomerAdditionalConfigFields = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>(); const { control } = useFormContext<CreateCustomerFormData>();
return ( return (
<Card className='border-0 shadow-none'> <Card className='border-0 shadow-none'>

View File

@ -9,11 +9,11 @@ import {
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { COUNTRY_OPTIONS } from "../../constants"; import { COUNTRY_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas"; import { CreateCustomerFormData } from "../../schemas";
export const CustomerAddressFields = () => { export const CustomerAddressFields = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>(); const { control } = useFormContext<CreateCustomerFormData>();
return ( return (
<Card className='border-0 shadow-none'> <Card className='border-0 shadow-none'>

View File

@ -15,11 +15,11 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas"; import { CreateCustomerFormData } from "../../schemas";
export const CustomerBasicInfoFields = () => { export const CustomerBasicInfoFields = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>(); const { control } = useFormContext<CreateCustomerFormData>();
const isCompany = useWatch({ const isCompany = useWatch({
control, control,

View File

@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, FormProvider, useForm } from "react-hook-form"; import { FieldErrors, FormProvider, useForm } from "react-hook-form";
import { useEffect } from "react"; import { useEffect } from "react";
import { CreateCustomerFormSchema, CustomerFormData } from "../../schemas"; import { CreateCustomerFormData, CreateCustomerFormSchema } from "../../schemas";
import { FormDebug } from "../form-debug"; import { FormDebug } from "../form-debug";
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";
@ -11,9 +11,9 @@ import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps { interface CustomerFormProps {
formId: string; formId: string;
initialValues: CustomerFormData; initialValues: CreateCustomerFormData;
onSubmit: (data: CustomerFormData) => void; onSubmit: (data: CreateCustomerFormData) => void;
onError: (errors: FieldErrors<CustomerFormData>) => void; onError: (errors: FieldErrors<CreateCustomerFormData>) => void;
disabled?: boolean; disabled?: boolean;
onDirtyChange: (isDirty: boolean) => void; onDirtyChange: (isDirty: boolean) => void;
} }
@ -26,7 +26,7 @@ export function CustomerEditForm({
disabled, disabled,
onDirtyChange, onDirtyChange,
}: CustomerFormProps) { }: CustomerFormProps) {
const form = useForm<CustomerFormData>({ const form = useForm<CreateCustomerFormData>({
resolver: zodResolver(CreateCustomerFormSchema), resolver: zodResolver(CreateCustomerFormSchema),
defaultValues: initialValues, defaultValues: initialValues,
disabled, disabled,

View File

@ -2,11 +2,11 @@ import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema, CustomerCreationResponseDTO } from "../../common"; import { CreateCustomerRequestSchema, CustomerCreationResponseDTO } from "../../common";
import { CustomerFormData } from "../schemas"; import { CreateCustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation"; import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
type CreateCustomerPayload = { type CreateCustomerPayload = {
data: CustomerFormData; data: CreateCustomerFormData;
}; };
export function useCreateCustomerMutation() { export function useCreateCustomerMutation() {
@ -34,8 +34,6 @@ export function useCreateCustomerMutation() {
message: err.message, message: err.message,
})); }));
console.debug(validationErrors);
throw new ValidationErrorCollection("Validation failed", validationErrors); throw new ValidationErrorCollection("Validation failed", validationErrors);
} }

View File

@ -1,20 +1,23 @@
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CustomerData, CustomerUpdateData } from "../schemas"; import { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdRequestSchema } from "../../common";
import { CreateCustomerFormData } from "../schemas";
import { CUSTOMER_QUERY_KEY } from "./use-customer-query"; import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const; export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type UpdateCustomerPayload = { type UpdateCustomerPayload = {
id: string; id: string;
data: CustomerUpdateData; data: CreateCustomerFormData;
}; };
export function useUpdateCustomerMutation() { export function useUpdateCustomerMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<CustomerData, Error, UpdateCustomerPayload>({ return useMutation<UpdateCustomerByIdRequestDTO, Error, UpdateCustomerPayload>({
mutationKey: ["customer:update"], //, customerId], mutationKey: ["customer:update"], //, customerId],
mutationFn: async (payload) => { mutationFn: async (payload) => {
@ -23,14 +26,28 @@ export function useUpdateCustomerMutation() {
throw new Error("customerId is required"); throw new Error("customerId is required");
} }
const result = schema.safeParse(data);
if (!result.success) {
// Construye errores detallados
const validationErrors = result.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
throw new ValidationErrorCollection("Validation failed", validationErrors);
}
const updated = await dataSource.updateOne("customers", customerId, data); const updated = await dataSource.updateOne("customers", customerId, data);
return updated as CustomerData; return updated as UpdateCustomerByIdRequestDTO;
}, },
onSuccess: (updated, variables) => { onSuccess: (updated, variables) => {
const { id: customerId } = variables; const { id: customerId } = variables;
// Refresca inmediatamente el detalle // Refresca inmediatamente el detalle
queryClient.setQueryData<CustomerData>(CUSTOMER_QUERY_KEY(customerId), updated); queryClient.setQueryData<UpdateCustomerByIdRequestDTO>(
CUSTOMER_QUERY_KEY(customerId),
updated
);
// Otra opción es invalidar el detalle para forzar refetch: // Otra opción es invalidar el detalle para forzar refetch:
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) }); // queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });

View File

@ -8,14 +8,14 @@ import { FieldErrors } from "react-hook-form";
import { CustomerEditForm, ErrorAlert } from "../../components"; import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCreateCustomerMutation } from "../../hooks"; import { useCreateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData, defaultCustomerFormData } from "../../schemas"; import { CreateCustomerFormData, defaultCustomerFormData } from "../../schemas";
export const CustomerCreate = () => { export const CustomerCreate = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
// 2) Estado de creación (mutación) // 1) Estado de creación (mutación)
const { const {
mutate, mutate,
isPending: isCreating, isPending: isCreating,
@ -23,8 +23,8 @@ export const CustomerCreate = () => {
error: createError, error: createError,
} = useCreateCustomerMutation(); } = useCreateCustomerMutation();
// 3) Submit con navegación condicionada por éxito // 2) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => { const handleSubmit = (formData: CreateCustomerFormData) => {
mutate( mutate(
{ data: formData }, { data: formData },
{ {
@ -48,7 +48,7 @@ export const CustomerCreate = () => {
); );
}; };
const handleError = (errors: FieldErrors<CustomerFormData>) => { const handleError = (errors: FieldErrors<CreateCustomerFormData>) => {
console.error("Errores en el formulario:", errors); console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
}; };

View File

@ -1,7 +1,7 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { CustomersListGrid } from "../components"; import { CustomersListGrid } from "../components";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
@ -28,6 +28,7 @@ export const CustomersList = () => {
<div className='flex flex-col w-full h-full py-4'> <div className='flex flex-col w-full h-full py-4'>
<CustomersListGrid /> <CustomersListGrid />
</div> </div>
<Outlet />
</AppContent> </AppContent>
</> </>
); );

View File

@ -1,9 +1,9 @@
import { AppBreadcrumb, AppContent, BackHistoryButton, ButtonGroup } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useUrlParamId } from "@erp/core/hooks"; import { FormCommitButtonGroup, UnsavedChangesProvider, useUrlParamId } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils"; import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react";
import { FieldErrors } from "react-hook-form"; import { FieldErrors } from "react-hook-form";
import { import {
CustomerEditForm, CustomerEditForm,
@ -13,12 +13,13 @@ import {
} from "../../components"; } from "../../components";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks"; import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas"; import { CreateCustomerFormData } from "../../schemas";
export const CustomerUpdate = () => { export const CustomerUpdate = () => {
const customerId = useUrlParamId(); const customerId = useUrlParamId();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 1) Estado de carga del cliente (query) // 1) Estado de carga del cliente (query)
const { const {
@ -30,30 +31,38 @@ export const CustomerUpdate = () => {
// 2) Estado de actualización (mutación) // 2) Estado de actualización (mutación)
const { const {
mutateAsync, mutate,
isPending: isUpdating, isPending: isUpdating,
isError: isUpdateError, isError: isUpdateError,
error: updateError, error: updateError,
} = useUpdateCustomerMutation(); } = useUpdateCustomerMutation();
// 3) Submit con navegación condicionada por éxito // 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerFormData) => { const handleSubmit = (formData: CreateCustomerFormData) => {
console.log(formData); mutate(
try { { id: customerId!, data: formData },
const result = await mutateAsync({ id: customerId!, data: formData }); {
console.log(result); onSuccess(data) {
setIsDirty(false);
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
if (result) { // El timeout es para que a React le dé tiempo a procesar
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg")); // el cambio de estado de isDirty / setIsDirty.
navigate("/customers/list", { relative: "path" }); setTimeout(() => {
navigate("/customers/list", {
state: { customerId: data.id, isNew: true },
replace: true,
});
}, 0);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
},
} }
} catch (e) { );
showErrorToast(t("pages.update.errorTitle"), (e as Error).message);
} finally {
}
}; };
const handleError = (errors: FieldErrors<CustomerFormData>) => { const handleError = (errors: FieldErrors<CreateCustomerFormData>) => {
console.error("Errores en el formulario:", errors); console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
}; };
@ -100,60 +109,48 @@ export const CustomerUpdate = () => {
<> <>
<AppBreadcrumb /> <AppBreadcrumb />
<AppContent> <AppContent>
<div className='flex items-center justify-between space-y-4 px-6'> <UnsavedChangesProvider isDirty={isDirty}>
<div className='space-y-2'> <div className='flex items-center justify-between space-y-4 px-6'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'> <div className='space-y-2'>
{t("pages.update.title")} <h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
</h2> {t("pages.update.title")}
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'> </h2>
{t("pages.update.description")} <p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
</p> {t("pages.update.description")}
</div> </p>
<ButtonGroup> </div>
<Button <FormCommitButtonGroup
variant={"outline"} cancel={{
className='cursor-pointer' to: "/customers/list",
onClick={(e) => {
e.preventDefault();
navigate("/customers/list");
}} }}
> submit={{
{t("common.cancel")} formId: "customer-create-form",
</Button> disabled: isUpdating,
isLoading: isUpdating,
}}
/>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<Button <div className='flex flex-1 flex-col gap-4 p-4'>
type='submit' <CustomerEditForm
form='customer-update-form' formId={"customer-update-form"} // para que el botón del header pueda hacer submit
className='cursor-pointer' initialValues={customerData}
disabled={isUpdating || isLoadingCustomer} onSubmit={handleSubmit}
aria-busy={isUpdating} onError={handleError}
aria-disabled={isUpdating || isLoadingCustomer} onDirtyChange={setIsDirty}
data-state={isUpdating ? "loading" : "idle"} />
> </div>
{t("common.save")} </UnsavedChangesProvider>
</Button>
</ButtonGroup>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-update-form"} // para que el botón del header pueda hacer submit
initialValues={customerData}
onSubmit={handleSubmit}
onError={handleError}
disabled={isLoadingCustomer}
/>
</div>
</AppContent> </AppContent>
</> </>
); );

View File

@ -55,9 +55,9 @@ export const CreateCustomerFormSchema = z.object({
.default("EUR"), .default("EUR"),
}); });
export type CustomerFormData = z.infer<typeof CreateCustomerFormSchema>; export type CreateCustomerFormData = z.infer<typeof CreateCustomerFormSchema>;
export const defaultCustomerFormData: CustomerFormData = { export const defaultCustomerFormData: CreateCustomerFormData = {
reference: "", reference: "",
is_company: "true", is_company: "true",

View File

@ -0,0 +1,15 @@
import * as z from "zod/v4";
import { CreateCustomerFormSchema } from "./customer-create.form.schema";
export const UpdateCustomerFormSchema = CreateCustomerFormSchema.extend({
is_company: CreateCustomerFormSchema.shape.is_company.optional(),
name: CreateCustomerFormSchema.shape.name.optional(),
default_taxes: z.array(z.string()).optional(),
country: CreateCustomerFormSchema.shape.country.optional(),
language_code: CreateCustomerFormSchema.shape.language_code.optional(),
currency_code: CreateCustomerFormSchema.shape.currency_code.optional(),
});
export type UpdateCustomerFormData = z.infer<typeof UpdateCustomerFormSchema>;

View File

@ -1,2 +1,2 @@
export * from "./customer-create.form.schema";
export * from "./customer.api.schema"; export * from "./customer.api.schema";
export * from "./customer.form.schema";

View File

@ -0,0 +1,47 @@
import type React from "react";
import { Button, Dialog, DialogContent } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { X } from "lucide-react";
interface FullscreenModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
className?: string;
}
export const FullscreenModal = ({
isOpen,
onClose,
title,
children,
className,
}: FullscreenModalProps) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn(
"max-w-none max-h-none w-screen h-screen",
"bg-background border-0 rounded-none p-0",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
>
{/* Header fijo */}
<div className='flex items-center justify-between p-6 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
<h2 className='text-2xl font-semibold tracking-tight'>{title}</h2>
<Button variant='ghost' size='sm' onClick={onClose} className='h-8 w-8 p-0'>
<X className='h-4 w-4' />
<span className='sr-only'>Cerrar</span>
</Button>
</div>
{/* Contenido scrolleable */}
<div className='flex-1 overflow-auto p-6'>{children}</div>
</DialogContent>
</Dialog>
);
};

View File

@ -4,6 +4,7 @@ export * from "./datatable/index.tsx";
export * from "./dynamics-tabs.tsx"; export * from "./dynamics-tabs.tsx";
export * from "./error-overlay.tsx"; export * from "./error-overlay.tsx";
export * from "./form/index.tsx"; export * from "./form/index.tsx";
export * from "./full-screen-modal.tsx";
export * from "./grid/index.ts"; export * from "./grid/index.ts";
export * from "./layout/index.tsx"; export * from "./layout/index.tsx";
export * from "./loading-overlay/index.tsx"; export * from "./loading-overlay/index.tsx";

View File

@ -2,7 +2,7 @@ import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { AppSidebar } from "./app-sidebar.tsx"; import { AppSidebar } from "./app-sidebar.tsx";
export const AppLayout: React.FC = () => { export const AppLayout = () => {
return ( return (
<SidebarProvider <SidebarProvider
style={ style={