diff --git a/apps/web/package.json b/apps/web/package.json index 95328225..2366aa0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "i18next-browser-languagedetector": "^8.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-error-boundary": "^6.0.0", "react-hook-form": "^7.56.4", "react-i18next": "^15.0.1", "react-router-dom": "^6.26.0", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 01d596ae..d734c372 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,16 +3,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { I18nextProvider } from "react-i18next"; -import { UnsavedWarnProvider } from "@/lib/hooks"; import { i18n } from "@/locales"; import { AuthProvider, createAuthService } from "@erp/auth/client"; import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; -import { DataSourceProvider } from "@erp/core/hooks"; +import { DataSourceProvider, UnsavedWarnProvider } from "@erp/core/hooks"; import DineroFactory from "dinero.js"; -import "./app.css"; +import { RouterProvider } from "react-router-dom"; import { clearAccessToken, getAccessToken, setAccessToken } from "./lib"; -import { AppRoutes } from "./routes"; +import { getAppRouter } from "./routes"; + +import { LoadingOverlay } from "@repo/rdx-ui/components"; +import { Suspense } from "react"; +import "./app.css"; export const App = () => { DineroFactory.globalLocale = "es-ES"; @@ -37,6 +40,7 @@ export const App = () => { }); const dataSource = createAxiosDataSource(axiosInstance); + const appRouter = getAppRouter(); return ( @@ -52,7 +56,10 @@ export const App = () => { > - + {/* Fallback Route */} + }> + + diff --git a/apps/web/src/components/error-fallbacks.tsx b/apps/web/src/components/error-fallbacks.tsx new file mode 100644 index 00000000..6d241bab --- /dev/null +++ b/apps/web/src/components/error-fallbacks.tsx @@ -0,0 +1,214 @@ +import { Button } from "@repo/shadcn-ui/components"; +import * as React from "react"; +import { FallbackProps } from "react-error-boundary"; + +/** + * 1) Fallback simple + */ +export function SimpleFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+

⚠️ Algo salió mal

+
{error?.message}
+ +
+ ); +} + +/** + * 2) Fallback de sección (tarjeta compacta) + */ +export function SectionCardFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+
+
+
+

No se pudo cargar esta sección

+

{error?.message}

+
+ + +
+
+
+
+ ); +} + +/** + * 3) Fallback pantalla completa + */ +export function FullPageFallback({ error }: FallbackProps) { + return ( +
+
😵
+

Ocurrió un error inesperado

+

{error?.message}

+
+ + Volver al inicio + + +
+
+ ); +} + +/** + * 4) Fallback para listas + */ +export function ListFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+
+ 🗂️ +
+

No pudimos cargar la lista.

+

{error?.message}

+
+ + +
+
+
+
+ ); +} + +/** + * 5) Fallback para formularios + */ +export function FormFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+

No se pudo mostrar el formulario

+

{error?.message}

+
+ + +
+
+ ); +} + +/** + * 6) Fallback para problemas de red + */ +export function NetworkFallback({ error, resetErrorBoundary }: FallbackProps) { + const note = navigator.onLine + ? "La conexión parece estar activa." + : "Estás sin conexión. Revisa tu red y reintenta."; + + return ( +
+
No pudimos obtener los datos
+

{error?.message}

+

{note}

+
+ + +
+
+ ); +} + +/** + * 7) Fallback modo desarrollador (detalle de stack) + */ +export function DevDetailsFallback({ error, resetErrorBoundary }: FallbackProps) { + const [open, setOpen] = React.useState(false); + return ( +
+
+

Algo falló

+
+ + +
+
+ {open && ( +
+          {error?.stack || error?.message}
+        
+ )} +
+ ); +} + +/** + * 8) Fallback con soporte (link a ayuda) + */ +export function SupportFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+

No pudimos completar la acción

+

{error?.message}

+
+ + + Ir a Ayuda + + + Contactar soporte + +
+
+ ); +} diff --git a/apps/web/src/components/index.tsx b/apps/web/src/components/index.tsx index b2dfcd49..0ae3c55d 100644 --- a/apps/web/src/components/index.tsx +++ b/apps/web/src/components/index.tsx @@ -1 +1,2 @@ +export * from "./error-fallbacks"; export * from "./slider-demo"; diff --git a/apps/web/src/lib/hooks/index.ts b/apps/web/src/lib/hooks/index.ts index 775fb608..767b68bf 100644 --- a/apps/web/src/lib/hooks/index.ts +++ b/apps/web/src/lib/hooks/index.ts @@ -1,2 +1 @@ export * from "./use-theme"; -export * from "./use-unsaved-changes-notifier"; diff --git a/apps/web/src/lib/hooks/use-unsaved-changes-notifier/index.ts b/apps/web/src/lib/hooks/use-unsaved-changes-notifier/index.ts deleted file mode 100644 index 223210e0..00000000 --- a/apps/web/src/lib/hooks/use-unsaved-changes-notifier/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./warn-about-change-provider"; diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index 42479ff8..46891565 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -1,8 +1,7 @@ import { ModuleRoutes } from "@/components/module-routes"; import { IModuleClient } from "@erp/core/client"; -import { AppLayout, LoadingOverlay, ScrollToTop } from "@repo/rdx-ui/components"; -import { JSX, Suspense } from "react"; -import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom"; +import { AppLayout } from "@repo/rdx-ui/components"; +import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import { ErrorPage } from "../pages"; import { modules } from "../register-modules"; // Aquí ca @@ -25,41 +24,36 @@ function groupModulesByLayout(modules: IModuleClient[]) { return groups; } -export const AppRoutes = (): JSX.Element => { +export const getAppRouter = () => { const params = { ...import.meta.env, }; const grouped = groupModulesByLayout(modules); - console.log(grouped); + console.debug(grouped); - return ( - - + return createBrowserRouter( + createRoutesFromElements( + + {/* Auth Layout */} + + } /> + } /> + - {/* Fallback Route */} - }> - - {/* Auth Layout */} - - } /> - } /> - + {/* App Layout */} + }> + {/* Dynamic Module Routes */} + } /> - {/* App Layout */} - }> - {/* Dynamic Module Routes */} - } /> - - {/* Main Layout */} - } /> - } /> - } /> - } /> - - - - + {/* Main Layout */} + } /> + } /> + } /> + } /> + + + ) ); }; diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 9eb96c89..31396066 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -27,6 +27,6 @@ "noUncheckedSideEffectImports": true, "allowUnreachableCode": true }, - "include": ["src"], + "include": ["src", "../../modules/core/src/web/hooks/use-unsaved-changes-notifier"], "exclude": ["node_modules"] } diff --git a/modules/core/src/common/locales/en.json b/modules/core/src/common/locales/en.json index 80b90eb8..8af37c72 100644 --- a/modules/core/src/common/locales/en.json +++ b/modules/core/src/common/locales/en.json @@ -1,5 +1,7 @@ { - "common": {}, + "common": { + "required": "required" + }, "components": { "taxes_multi_select": { "label": "Taxes", @@ -7,5 +9,13 @@ "description": "Select the taxes to apply to the invoice items", "invalid_tax_selection": "Invalid tax selection. Please select a valid tax." } + }, + "hooks": { + "use_unsaved_changes_notifier": { + "unsaved_changes": "You have unsaved changes", + "unsaved_changes_explanation": "If you leave, your changes will be lost.", + "discard_changes": "Discard changes", + "stay_on_page": "Stay on page" + } } } diff --git a/modules/core/src/common/locales/es.json b/modules/core/src/common/locales/es.json index 22dafa53..a59dc5b2 100644 --- a/modules/core/src/common/locales/es.json +++ b/modules/core/src/common/locales/es.json @@ -1,5 +1,7 @@ { - "common": {}, + "common": { + "required": "requerido" + }, "components": { "taxes_multi_select": { "label": "Impuestos", @@ -7,5 +9,13 @@ "description": "Seleccionar los impuestos a aplicar a los artículos de la factura", "invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido." } + }, + "hooks": { + "use_unsaved_changes_notifier": { + "unsaved_changes": "Tienes cambios no guardados", + "unsaved_changes_explanation": "Si sales, tus cambios se perderán.", + "discard_changes": "Descartar cambios", + "stay_on_page": "Permanecer en la página" + } } } diff --git a/modules/core/src/web/hooks/index.ts b/modules/core/src/web/hooks/index.ts index 42765c58..be28752e 100644 --- a/modules/core/src/web/hooks/index.ts +++ b/modules/core/src/web/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./use-datasource"; export * from "./use-pagination"; export * from "./use-query-key"; export * from "./use-toggle"; +export * from "./use-unsaved-changes-notifier"; 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 new file mode 100644 index 00000000..51391328 --- /dev/null +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/index.ts @@ -0,0 +1,2 @@ +export * from "./use-unsaved-changes-notifier"; +export * from "./warn-about-change-provider"; diff --git a/apps/web/src/lib/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 similarity index 55% rename from apps/web/src/lib/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx rename to modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx index 0c3b7ad6..8ae6b264 100644 --- a/apps/web/src/lib/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,9 +1,10 @@ -import { t } from "i18next"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useBlocker } from "react-router-dom"; +import { useTranslation } from "../../i18n"; import { useWarnAboutChange } from "./use-warn-about-change"; export type UnsavedChangesNotifierProps = { + isDirty?: boolean; title?: string; subtitle?: string; confirmText?: string; @@ -15,26 +16,37 @@ export type UnsavedChangesNotifierProps = { export const useUnsavedChangesNotifier = ({ isDirty = false, - title = t("hooks.use_unsaved_changes_notifier.title"), - subtitle = t("hooks.use_unsaved_changes_notifier.subtitle"), - confirmText = t("hooks.use_unsaved_changes_notifier.confirm_text"), - cancelText = t("hooks.use_unsaved_changes_notifier.cancel_text"), + title, + subtitle, + confirmText, + cancelText, onConfirm, onCancel, type = "warning", -}: UnsavedChangesNotifierProps & { isDirty?: boolean }) => { +}: UnsavedChangesNotifierProps = {}) => { + const { t } = useTranslation(); const blocker = useBlocker(isDirty); const { show } = useWarnAboutChange(); + 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] + ); + const confirm = useCallback(() => { if (!isDirty) return Promise.resolve(true); return new Promise((resolve) => { show({ - title, - subtitle, - confirmText, - cancelText, + title: texts.title, + subtitle: texts.subtitle, + confirmText: texts.confirmText, + cancelText: texts.cancelText, type, onConfirm: () => { resolve(true); @@ -46,7 +58,7 @@ export const useUnsavedChangesNotifier = ({ }, }); }); - }, [cancelText, confirmText, isDirty, onCancel, onConfirm, show, subtitle, title, type]); + }, [isDirty, show, texts, type, onConfirm, onCancel]); useEffect(() => { if (blocker.state === "blocked") { @@ -59,13 +71,13 @@ export const useUnsavedChangesNotifier = ({ useEffect(() => { if (isDirty) { - window.onbeforeunload = () => subtitle; + window.onbeforeunload = () => texts.subtitle; } return () => { window.onbeforeunload = null; }; - }, [isDirty, subtitle]); + }, [isDirty, texts.subtitle]); return { confirm, diff --git a/apps/web/src/lib/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 similarity index 100% rename from apps/web/src/lib/hooks/use-unsaved-changes-notifier/use-warn-about-change.tsx rename to modules/core/src/web/hooks/use-unsaved-changes-notifier/use-warn-about-change.tsx diff --git a/apps/web/src/lib/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 similarity index 88% rename from apps/web/src/lib/hooks/use-unsaved-changes-notifier/warn-about-change-context.tsx rename to modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-context.tsx index f8cfcd7c..0eaea95b 100644 --- a/apps/web/src/lib/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 @@ -1,5 +1,5 @@ +import { NullOr } from "@repo/rdx-utils"; import { createContext } from "react"; -import type { NullOr } from "../../types"; import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier"; export interface IUnsavedWarnContextState { diff --git a/apps/web/src/lib/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 similarity index 96% rename from apps/web/src/lib/hooks/use-unsaved-changes-notifier/warn-about-change-provider.tsx rename to modules/core/src/web/hooks/use-unsaved-changes-notifier/warn-about-change-provider.tsx index 3766b70e..623362c6 100644 --- a/apps/web/src/lib/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 @@ -1,6 +1,6 @@ import { CustomDialog } from "@repo/rdx-ui/components"; +import { NullOr } from "@repo/rdx-utils"; import { type PropsWithChildren, useCallback, useMemo, useState } from "react"; -import type { NullOr } from "../../types"; import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier"; import { UnsavedWarnContext } from "./warn-about-change-context"; diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts index 7b47a58b..a9d8392a 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts @@ -40,12 +40,8 @@ export const createAxiosInstance = ({ getAccessToken, onAuthError, }: AxiosFactoryConfig): AxiosInstance => { - console.log({ baseURL, getAccessToken, onAuthError }); - const instance = axios.create(defaultAxiosRequestConfig); - instance.defaults.baseURL = baseURL; - setupInterceptors(instance, getAccessToken, onAuthError); return instance; diff --git a/modules/customers/src/web/pages/create/create.tsx b/modules/customers/src/web/pages/create/create.tsx index ff844792..a9a38a9a 100644 --- a/modules/customers/src/web/pages/create/create.tsx +++ b/modules/customers/src/web/pages/create/create.tsx @@ -1,6 +1,6 @@ import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; import { Button } from "@repo/shadcn-ui/components"; -import { useNavigate } from "react-router-dom"; +import { useBlocker, useNavigate } from "react-router-dom"; import { useCreateCustomerMutation } from "../../hooks/use-create-customer-mutation"; import { useTranslation } from "../../i18n"; @@ -9,6 +9,7 @@ import { CustomerEditForm } from "./customer-edit-form"; export const CustomerCreate = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const { block, unblock } = useBlocker(1); const { mutate, isPending, isError, error } = useCreateCustomerMutation(); diff --git a/modules/customers/src/web/pages/create/customer-edit-form.tsx b/modules/customers/src/web/pages/create/customer-edit-form.tsx index 41cb5b8d..15e93146 100644 --- a/modules/customers/src/web/pages/create/customer-edit-form.tsx +++ b/modules/customers/src/web/pages/create/customer-edit-form.tsx @@ -14,6 +14,8 @@ import { RadioGroup, RadioGroupItem, } from "@repo/shadcn-ui/components"; + +import { useUnsavedChangesNotifier } from "@erp/core/hooks"; import { useTranslation } from "../../i18n"; import { CustomerData, CustomerDataFormSchema } from "./customer.schema"; @@ -45,6 +47,11 @@ export const CustomerEditForm = ({ const form = useForm({ resolver: zodResolver(CustomerDataFormSchema), defaultValues: initialData, + disabled: isPending, + }); + + useUnsavedChangesNotifier({ + isDirty: form.formState.isDirty, }); const handleSubmit = (data: CustomerData) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e4f643f..880806f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,7 +170,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))(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) + 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) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -228,6 +228,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.1.0) react-hook-form: specifier: ^7.56.4 version: 7.58.1(react@19.1.0) @@ -5335,6 +5338,11 @@ packages: peerDependencies: react: ^19.1.0 + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + react-hook-form@7.58.1: resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==} engines: {node: '>=18.0.0'} @@ -11044,6 +11052,11 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-error-boundary@6.0.0(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.6 + react: 19.1.0 + react-hook-form@7.58.1(react@19.1.0): dependencies: react: 19.1.0 @@ -11655,7 +11668,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))(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): + 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): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11673,7 +11686,6 @@ 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):