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 { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
import { CreateCustomerFormData } from "../../schemas";
export const CustomerAdditionalConfigFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
const { control } = useFormContext<CreateCustomerFormData>();
return (
<Card className='border-0 shadow-none'>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,23 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
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";
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type UpdateCustomerPayload = {
id: string;
data: CustomerUpdateData;
data: CreateCustomerFormData;
};
export function useUpdateCustomerMutation() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<CustomerData, Error, UpdateCustomerPayload>({
return useMutation<UpdateCustomerByIdRequestDTO, Error, UpdateCustomerPayload>({
mutationKey: ["customer:update"], //, customerId],
mutationFn: async (payload) => {
@ -23,14 +26,28 @@ export function useUpdateCustomerMutation() {
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);
return updated as CustomerData;
return updated as UpdateCustomerByIdRequestDTO;
},
onSuccess: (updated, variables) => {
const { id: customerId } = variables;
// 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:
// 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 { useCreateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData, defaultCustomerFormData } from "../../schemas";
import { CreateCustomerFormData, defaultCustomerFormData } from "../../schemas";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 2) Estado de creación (mutación)
// 1) Estado de creación (mutación)
const {
mutate,
isPending: isCreating,
@ -23,8 +23,8 @@ export const CustomerCreate = () => {
error: createError,
} = useCreateCustomerMutation();
// 3) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => {
// 2) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CreateCustomerFormData) => {
mutate(
{ 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);
// 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 { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Outlet, useNavigate } from "react-router-dom";
import { CustomersListGrid } from "../components";
import { useTranslation } from "../i18n";
@ -28,6 +28,7 @@ export const CustomersList = () => {
<div className='flex flex-col w-full h-full py-4'>
<CustomersListGrid />
</div>
<Outlet />
</AppContent>
</>
);

View File

@ -1,9 +1,9 @@
import { AppBreadcrumb, AppContent, BackHistoryButton, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
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 { useState } from "react";
import { FieldErrors } from "react-hook-form";
import {
CustomerEditForm,
@ -13,12 +13,13 @@ import {
} from "../../components";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
import { CreateCustomerFormData } from "../../schemas";
export const CustomerUpdate = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 1) Estado de carga del cliente (query)
const {
@ -30,30 +31,38 @@ export const CustomerUpdate = () => {
// 2) Estado de actualización (mutación)
const {
mutateAsync,
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomerMutation();
// 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerFormData) => {
console.log(formData);
try {
const result = await mutateAsync({ id: customerId!, data: formData });
console.log(result);
const handleSubmit = (formData: CreateCustomerFormData) => {
mutate(
{ id: customerId!, data: formData },
{
onSuccess(data) {
setIsDirty(false);
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
if (result) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
navigate("/customers/list", { relative: "path" });
// El timeout es para que a React le dé tiempo a procesar
// el cambio de estado de isDirty / setIsDirty.
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);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
@ -100,60 +109,48 @@ export const CustomerUpdate = () => {
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.update.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.update.description")}
</p>
</div>
<ButtonGroup>
<Button
variant={"outline"}
className='cursor-pointer'
onClick={(e) => {
e.preventDefault();
navigate("/customers/list");
<UnsavedChangesProvider isDirty={isDirty}>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.update.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.update.description")}
</p>
</div>
<FormCommitButtonGroup
cancel={{
to: "/customers/list",
}}
>
{t("common.cancel")}
</Button>
submit={{
formId: "customer-create-form",
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
type='submit'
form='customer-update-form'
className='cursor-pointer'
disabled={isUpdating || isLoadingCustomer}
aria-busy={isUpdating}
aria-disabled={isUpdating || isLoadingCustomer}
data-state={isUpdating ? "loading" : "idle"}
>
{t("common.save")}
</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>
<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}
onDirtyChange={setIsDirty}
/>
</div>
</UnsavedChangesProvider>
</AppContent>
</>
);

View File

@ -55,9 +55,9 @@ export const CreateCustomerFormSchema = z.object({
.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: "",
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.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 "./error-overlay.tsx";
export * from "./form/index.tsx";
export * from "./full-screen-modal.tsx";
export * from "./grid/index.ts";
export * from "./layout/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 { AppSidebar } from "./app-sidebar.tsx";
export const AppLayout: React.FC = () => {
export const AppLayout = () => {
return (
<SidebarProvider
style={