diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index b0912ba1..4283d682 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -7,7 +7,7 @@ import { i18n } from "@/locales"; import { AuthProvider, createAuthService } from "@erp/auth/client"; import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; -import { DataSourceProvider, UnsavedWarnProvider } from "@erp/core/hooks"; +import { DataSourceProvider } from "@erp/core/hooks"; import DineroFactory from "dinero.js"; import { RouterProvider } from "react-router-dom"; import { clearAccessToken, getAccessToken, setAccessToken } from "./lib"; @@ -55,12 +55,10 @@ export const App = () => { }} > - - {/* Fallback Route */} - }> - - - + {/* Fallback Route */} + }> + + void | Promise; // Prioritaria sobre "to" + label?: string; + variant?: React.ComponentProps["variant"]; + size?: React.ComponentProps["size"]; + className?: string; + disabled?: boolean; + "data-testid"?: string; +}; + +export const CancelFormButton = ({ + to, + onCancel, + label, + variant = "outline", + size = "default", + className, + disabled, + "data-testid": dataTestId = "cancel-button", +}: CancelFormButtonProps) => { + const navigate = useNavigate(); + + const { t } = useTranslation(); + const defaultLabel = t ? t("common.cancel") : "Cancel"; + const { requestConfirm } = useUnsavedChangesContext(); + + const handleClick = useCallback(async () => { + const ok = requestConfirm ? await requestConfirm() : true; + console.log("ok => ", ok); + if (!ok) return; + + if (onCancel) { + await onCancel(); + return; + } + + if (to) { + console.log("navego => ", to); + navigate(to); + } + // si no hay ni onCancel ni to → no hace nada + }, [requestConfirm, onCancel, to, navigate]); + + return ( + + {label ?? defaultLabel} + + ); +}; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx new file mode 100644 index 00000000..05c0c119 --- /dev/null +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx @@ -0,0 +1,47 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button"; +import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button"; + +type Align = "start" | "center" | "end" | "between"; + +export type FormCommitButtonGroupProps = { + className?: string; + align?: Align; // default "end" + gap?: string; // default "gap-2" + reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil) + cancel?: CancelFormButtonProps & { show?: boolean }; + submit?: SubmitButtonProps; // props directas a SubmitButton +}; + +const alignToJustify: Record = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + between: "justify-between", +}; + +export const FormCommitButtonGroup = ({ + className, + align = "end", + gap = "gap-2", + reverseOrderOnMobile = true, + cancel, + submit, +}: FormCommitButtonGroupProps) => { + const showCancel = cancel?.show ?? true; + + return ( + + {showCancel && } + {submit && } + + ); +}; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/index.ts b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/index.ts new file mode 100644 index 00000000..d396a4cb --- /dev/null +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/index.ts @@ -0,0 +1,4 @@ +export * from "./cancel-form-button"; +export * from "./form-commit-button-group"; +export * from "./submit-form-button"; +export * from "./unsaved-changes-dialog"; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx new file mode 100644 index 00000000..966ea34c --- /dev/null +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx @@ -0,0 +1,86 @@ +import { Button } from "@repo/shadcn-ui/components"; +import { LoaderCircleIcon } from "lucide-react"; +import * as React from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "../../../i18n.ts"; + +export type SubmitButtonProps = { + formId?: string; + isLoading?: boolean; + label?: string; + + variant?: React.ComponentProps["variant"]; + size?: React.ComponentProps["size"]; + className?: string; + + preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading + onClick?: React.MouseEventHandler; + disabled?: boolean; + children?: React.ReactNode; + "data-testid"?: string; +}; + +export const SubmitFormButton = ({ + formId, + isLoading, + label, + variant = "default", + size = "default", + className, + preventDoubleSubmit = true, + onClick, + disabled, + children, + "data-testid": dataTestId = "submit-button", +}: SubmitButtonProps) => { + const { t } = useTranslation(); + const defaultLabel = t ? t("common.save") : "Save"; + + // ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading + let rhfIsSubmitting = false; + try { + const ctx = useFormContext(); + rhfIsSubmitting = !!ctx?.formState?.isSubmitting; + } catch { + // No hay provider de RHF; ignorar + } + const busy = isLoading ?? rhfIsSubmitting; + + const computedDisabled = !!(disabled || (preventDoubleSubmit && busy)); + const dataState = busy ? "loading" : "idle"; + + const handleClick: React.MouseEventHandler = (e) => { + if (preventDoubleSubmit && busy) { + // Evitar submits duplicados durante loading + e.preventDefault(); + e.stopPropagation(); + return; + } + onClick?.(e); + }; + + return ( + + {children ? ( + children + ) : ( + + {busy && } + {label ?? defaultLabel} + + )} + + ); +}; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx new file mode 100644 index 00000000..d2bd83eb --- /dev/null +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx @@ -0,0 +1,29 @@ +import { CustomDialog } from "@repo/rdx-ui/components"; +import { useTranslation } from "../../../i18n"; + +type UnsavedChangesDialogProps = { + open: boolean; + onConfirm: (ok: boolean) => void; +}; + +export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) { + const { t } = useTranslation(); + + const defaultTitle = t ? t("hooks.unsaved_changes_dialog.title") : "¿Descartar cambios?"; + const defaultDescription = t + ? t("hooks.unsaved_changes_dialog.description") + : "Tienes cambios sin guardar. Si sales de esta página, se perderán."; + const defaultCancelLabel = t ? t("hooks.unsaved_changes_dialog.cancel") : "Seguir en esta página"; + const defaultConfirmLabel = t ? t("hooks.unsaved_changes_dialog.confirm") : "Descartar cambios"; + + return ( + + ); +} diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/index.ts b/modules/core/src/web/hooks/use-unsaved-changes-notifier/index.ts index a9f44ad1..5a02fdd3 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/index.ts +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/index.ts @@ -1,4 +1,2 @@ +export * from "./components"; export * from "./use-unsaved-changes-notifier"; -export * from "./use-warn-about-change"; -export * from "./warn-about-change-context"; -export * from "./warn-about-change-provider"; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx index 8a58f2d5..66f8b47e 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx @@ -1,89 +1,94 @@ -import { useCallback, useEffect, useMemo } from "react"; -import { useBlocker } from "react-router-dom"; -import { useTranslation } from "../../i18n"; -import { useWarnAboutChange } from "./use-warn-about-change"; +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { UNSAFE_NavigationContext, useBeforeUnload, useBlocker } from "react-router-dom"; +import { UnsavedChangesDialog } from "./components"; -export type UnsavedChangesNotifierProps = { - isDirty?: boolean; - title?: string; - subtitle?: string; - confirmText?: string; - cancelText?: string; - onConfirm?: () => void; - onCancel?: () => void; - type?: "success" | "error" | "warning" | "info"; +type ContextValue = { + requestConfirm: () => Promise; }; -export const useUnsavedChangesNotifier = ({ - isDirty = false, - title, - subtitle, - confirmText, - cancelText, - onConfirm, - onCancel, - type = "warning", -}: UnsavedChangesNotifierProps = {}) => { - const { t } = useTranslation(); - const blocker = useBlocker(isDirty); - const { show } = useWarnAboutChange(); +export const UnsavedChangesContext = createContext(null); - const texts = useMemo( - () => ({ - title: title ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes"), - subtitle: subtitle ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes_explanation"), - confirmText: confirmText ?? t("hooks.use_unsaved_changes_notifier.discard_changes"), - cancelText: cancelText ?? t("hooks.use_unsaved_changes_notifier.stay_on_page"), - }), - [t, title, subtitle, confirmText, cancelText] +export const useUnsavedChangesContext = () => { + const context = useContext(UnsavedChangesContext); + if (context === null) { + throw new Error("useUnsavedChangesContext must be used within an UnsavedChangesProvider"); + } + return context; +}; + +export function UnsavedChangesProvider({ + isDirty, + children, +}: { + isDirty: boolean; + children: React.ReactNode; +}) { + const [resolver, setResolver] = useState<(ok: boolean) => void>(); + const [open, setOpen] = useState(false); + + // requestConfirm() devuelve una promesa que resuelve true/false + // según lo que haya pulsado el usuario + const requestConfirm = useCallback(() => { + return new Promise((resolve) => { + setResolver(() => resolve); + setOpen(true); + }); + }, []); + + const handleConfirm = (ok: boolean) => { + resolver?.(ok); + setResolver(undefined); + setOpen(false); + }; + + // 🔹 bloquea F5/cierre de pestaña + useBeforeUnload( + (event) => { + if (isDirty) { + event.preventDefault(); + } + }, + { capture: true } ); - const confirm = useCallback(() => { - if (!isDirty) return Promise.resolve(true); + // 🔹 bloquea navegación interna (react-router) + const navigator = useContext(UNSAFE_NavigationContext).navigator as any; - return new Promise((resolve) => { - show({ - title: texts.title, - subtitle: texts.subtitle, - confirmText: texts.confirmText, - cancelText: texts.cancelText, - type, - onConfirm: () => { - resolve(true); - onConfirm?.(); - window.onbeforeunload = null; // limpirar de cambios - }, - onCancel: () => { - resolve(false); - onCancel?.(); - }, - }); - }); - }, [isDirty, show, texts, type, onConfirm, onCancel]); + useEffect(() => { + if (!isDirty) return; + + const push = navigator.push; + navigator.push = async (...args: any[]) => { + const ok = await requestConfirm(); + if (ok) push.apply(navigator, args); + }; + + return () => { + navigator.push = push; + }; + }, [isDirty, requestConfirm, navigator]); + + // 🔹 Bloquea navegación entre rutas + const blocker = useBlocker(() => isDirty); useEffect(() => { if (blocker.state === "blocked") { - confirm().then((result) => { - if (result) blocker.proceed(); - else blocker.reset(); - }); + (async () => { + const ok = await requestConfirm(); + if (ok) { + blocker.proceed(); + } else { + blocker.reset(); + } + })(); } - }, [blocker, confirm]); + }, [blocker, requestConfirm]); - useEffect(() => { - if (isDirty) { - window.onbeforeunload = (e) => { - e.preventDefault(); - return texts.subtitle; - }; - } else { - window.onbeforeunload = null; - } - - return () => { - window.onbeforeunload = null; - }; - }, [isDirty, texts.subtitle]); - - return { confirm }; -}; + return ( + + hola + {children} + + + ); +} diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-warn-about-change.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-warn-about-change.tsx deleted file mode 100644 index 14b478d9..00000000 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-warn-about-change.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from "react"; -import { UnsavedWarnContext } from "./warn-about-change-context"; - -export const useWarnAboutChange = () => { - const context = useContext(UnsavedWarnContext); - if (context === null) { - throw new Error("useWarnAboutChange must be used within an UnsavedWarnProvider"); - } - return context; -}; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-context.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-context.tsx deleted file mode 100644 index 37f13f78..00000000 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-context.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NullOr } from "@repo/rdx-utils"; -import { createContext } from "react"; -import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier"; - -export interface IUnsavedWarnContextState { - show: (options: NullOr) => void; - hide: () => void; -} - -export const UnsavedWarnContext = createContext>(null); diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-provider.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-provider.tsx deleted file mode 100644 index c15a4378..00000000 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-provider.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { CustomDialog } from "@repo/rdx-ui/components"; -import { NullOr } from "@repo/rdx-utils"; -import { type PropsWithChildren, useCallback, useMemo, useState } from "react"; -import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier"; -import { UnsavedWarnContext } from "./warn-about-change-context"; - -// Aseguramos que las props mínimas para el diálogo siempre estén presentes -type ConfirmOptions = Required< - Pick -> & - Omit; - -export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => { - const [confirm, setConfirm] = useState>(null); - const [open, setOpen] = useState(false); - - const show = useCallback((confirmOptions: NullOr) => { - if (confirmOptions) { - setConfirm(confirmOptions as ConfirmOptions); - setOpen(true); - } - }, []); - - const hide = useCallback(() => setOpen(false), []); - - const onConfirm = useCallback(() => { - confirm?.onConfirm?.(); - hide(); - }, [confirm, hide]); - - const onCancel = useCallback(() => { - confirm?.onCancel?.(); - hide(); - }, [confirm, hide]); - - const value = useMemo(() => ({ show, hide }), [show, hide]); - - return ( - - {children} - - - ); -}; diff --git a/modules/customers/src/web/pages/create/customer-create.tsx b/modules/customers/src/web/pages/create/customer-create.tsx index 72db8a03..64df4ca1 100644 --- a/modules/customers/src/web/pages/create/customer-create.tsx +++ b/modules/customers/src/web/pages/create/customer-create.tsx @@ -1,8 +1,7 @@ -import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components"; -import { Button } from "@repo/shadcn-ui/components"; +import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; import { useNavigate } from "react-router-dom"; -import { useUnsavedChangesNotifier } from "@erp/core/hooks"; +import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks"; import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils"; import { useState } from "react"; import { FieldErrors } from "react-hook-form"; @@ -24,10 +23,6 @@ export const CustomerCreate = () => { error: createError, } = useCreateCustomerMutation(); - const { confirm: confirmCancel } = useUnsavedChangesNotifier({ - isDirty, - }); - // 3) Submit con navegación condicionada por éxito const handleSubmit = async (formData: CustomerFormData) => { /*const changedData: Record = {}; @@ -56,68 +51,54 @@ export const CustomerCreate = () => { // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario }; + console.log("render"); + return ( <> - - - - {t("pages.create.title")} - - - {t("pages.create.description")} - - - - { - e.preventDefault(); - const ok = await confirmCancel(); - if (ok) { - console.log("Cambios descartados"); - navigate("/customers/list"); - } + + + + + {t("pages.create.title")} + + + {t("pages.create.description")} + + + - {t("common.cancel")} - + submit={{ + formId: "customer-create-form", + disabled: isCreating, + isLoading: isCreating, + }} + /> + + {/* Alerta de error de actualización (si ha fallado el último intento) */} + {isCreateError && ( + + )} - - {t("common.save")} - - - - {/* Alerta de error de actualización (si ha fallado el último intento) */} - {isCreateError && ( - - )} - - - - + + + + > ); diff --git a/packages/rdx-ui/src/components/custom-dialog.tsx b/packages/rdx-ui/src/components/custom-dialog.tsx index 8c63bcdc..66aab446 100644 --- a/packages/rdx-ui/src/components/custom-dialog.tsx +++ b/packages/rdx-ui/src/components/custom-dialog.tsx @@ -10,18 +10,16 @@ import { } from "@repo/shadcn-ui/components"; interface CustomDialogProps { - isOpen: boolean; - onCancel: () => void; - onConfirm: () => void; - title: React.ReactNode; - description: React.ReactNode; - cancelLabel: React.ReactNode; - confirmLabel: React.ReactNode; + open: boolean; + onConfirm: (ok: boolean) => void; + title?: string; + description?: string; + cancelLabel?: string; + confirmLabel?: string; } export const CustomDialog = ({ - isOpen, - onCancel, + open, onConfirm, title, description, @@ -29,15 +27,15 @@ export const CustomDialog = ({ confirmLabel, }: CustomDialogProps) => { return ( - !open && onCancel()}> + !open && onConfirm(false)}> {title} {description} - {cancelLabel} - {confirmLabel} + onConfirm(false)}>{cancelLabel} + onConfirm(true)}>{confirmLabel} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4215d69f..396e0a7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,7 +179,7 @@ importers: version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -395,6 +395,9 @@ importers: i18next: specifier: ^25.1.1 version: 25.2.1(typescript@5.8.3) + lucide-react: + specifier: ^0.503.0 + version: 0.503.0(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 @@ -12295,7 +12298,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -12313,6 +12316,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.27.4) + esbuild: 0.25.5 jest-util: 29.7.0 ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):
- {t("pages.create.description")} -
+ {t("pages.create.description")} +