diff --git a/apps/web/README.md b/apps/web/README.md index b5ff82e5..abd81a32 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -6,6 +6,7 @@ Aplicación frontend principal del ERP. - Flujo de autenticación y selección de empresa: [`../../docs/frontend/auth-and-company-selection.md`](../../docs/frontend/auth-and-company-selection.md) - Contexto tenant y reglas de `X-Company-Id`: [`../../docs/architecture/tenant-context.md`](../../docs/architecture/tenant-context.md) +- Tratamiento de errores HTTP en frontend: [`../../docs/architecture/http-error-handling-frontend.md`](../../docs/architecture/http-error-handling-frontend.md) ## Runtime actual de autenticación diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 39aca1d0..9e131f32 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -13,9 +13,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import DineroFactory from "dinero.js"; import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; import { I18nextProvider } from "react-i18next"; import { RouterProvider } from "react-router-dom"; +import { RenderErrorFallback } from "./pages"; import { getAppRouter } from "./routes"; export const App = () => { @@ -55,10 +57,20 @@ export const App = () => { - {/* Fallback Route */} - }> - - + ( + { + window.location.replace("/"); + }} + resetErrorBoundary={resetErrorBoundary} + /> + )} + > + }> + + + {import.meta.env.DEV && } diff --git a/apps/web/src/components/error-fallbacks.tsx b/apps/web/src/components/error-fallbacks.tsx deleted file mode 100644 index 62238385..00000000 --- a/apps/web/src/components/error-fallbacks.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { Button } from "@repo/shadcn-ui/components"; -import * as React from "react"; -import type { 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 0ae3c55d..b2dfcd49 100644 --- a/apps/web/src/components/index.tsx +++ b/apps/web/src/components/index.tsx @@ -1,2 +1 @@ -export * from "./error-fallbacks"; export * from "./slider-demo"; diff --git a/apps/web/src/layout/app-fullscreen-layout.tsx b/apps/web/src/layout/app-fullscreen-layout.tsx index 81d07584..9e15f073 100644 --- a/apps/web/src/layout/app-fullscreen-layout.tsx +++ b/apps/web/src/layout/app-fullscreen-layout.tsx @@ -1,11 +1,22 @@ import { Outlet } from "react-router-dom"; +import type React from "react"; import { AppMain } from "./app-main"; -export const AppFullscreenLayout = () => { +interface AppFullscreenLayoutFrameProps { + children: React.ReactNode; +} + +export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameProps) => { return ( - - - + {children} + ); +}; + +export const AppFullscreenLayout = () => { + return ( + + + ); }; diff --git a/apps/web/src/layout/app-sidebar-layout.tsx b/apps/web/src/layout/app-sidebar-layout.tsx index a5ef6dfc..5e836b53 100644 --- a/apps/web/src/layout/app-sidebar-layout.tsx +++ b/apps/web/src/layout/app-sidebar-layout.tsx @@ -6,7 +6,11 @@ import { AppMain } from "./app-main"; import { AppSidebar } from "./app-sidebar"; import { AppTopbar } from "./app-topbar"; -export const AppSidebarLayout = () => { +interface AppSidebarLayoutFrameProps { + children: React.ReactNode; +} + +export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps) => { return ( { - - - + {children} ); }; + +export const AppSidebarLayout = () => { + return ( + + + + ); +}; diff --git a/apps/web/src/layout/auth-layout.tsx b/apps/web/src/layout/auth-layout.tsx index cb8b27ba..c3042791 100644 --- a/apps/web/src/layout/auth-layout.tsx +++ b/apps/web/src/layout/auth-layout.tsx @@ -1,11 +1,22 @@ import { Outlet } from "react-router-dom"; +import type React from "react"; -export const AuthLayout = () => { +interface AuthLayoutFrameProps { + children: React.ReactNode; +} + +export const AuthLayoutFrame = ({ children }: AuthLayoutFrameProps) => { return (
-
- -
+
{children}
); }; + +export const AuthLayout = () => { + return ( + + + + ); +}; diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index f3ae5da4..c22d1c7f 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -1,3 +1,38 @@ { - "web": {} + "web": { + "routeErrors": { + "notFound": { + "title": "Page not found", + "description": "The address does not exist or has changed." + }, + "forbidden": { + "title": "You do not have access to this page", + "description": "You do not have permission to access this section." + }, + "server": { + "title": "This page could not be displayed", + "description": "An internal error occurred while loading the route." + }, + "unavailable": { + "title": "The service is unavailable", + "description": "The section you are trying to open is not available right now." + }, + "unknown": { + "title": "This page could not be displayed", + "description": "An unexpected error occurred while loading the route." + } + }, + "renderErrors": { + "unexpected": { + "title": "An unexpected error occurred", + "description": "This part of the application could not be displayed." + } + }, + "actions": { + "retry": "Try again", + "retrying": "Retrying…", + "goHome": "Go to home", + "goBack": "Go back" + } + } } diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index f3ae5da4..673e4160 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -1,3 +1,38 @@ { - "web": {} + "web": { + "routeErrors": { + "notFound": { + "title": "Página no encontrada", + "description": "La dirección no existe o ha cambiado." + }, + "forbidden": { + "title": "No tienes acceso a esta página", + "description": "No dispones de permisos para acceder a esta sección." + }, + "server": { + "title": "No se pudo mostrar esta página", + "description": "Se produjo un error interno mientras se cargaba la ruta." + }, + "unavailable": { + "title": "El servicio no está disponible", + "description": "La sección que intentas abrir no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo mostrar esta página", + "description": "Ha ocurrido un error inesperado mientras se cargaba la ruta." + } + }, + "renderErrors": { + "unexpected": { + "title": "Se ha producido un error inesperado", + "description": "No se ha podido mostrar esta parte de la aplicación." + } + }, + "actions": { + "retry": "Intentar de nuevo", + "retrying": "Reintentando…", + "goHome": "Ir al inicio", + "goBack": "Volver" + } + } } diff --git a/apps/web/src/locales/i18n.ts b/apps/web/src/locales/i18n.ts index d4b6efa8..ac6b5913 100644 --- a/apps/web/src/locales/i18n.ts +++ b/apps/web/src/locales/i18n.ts @@ -1,9 +1,6 @@ -import { initI18Next, registerTranslations } from "@repo/i18next"; -import { useTranslation as useI18NextTranslation } from "react-i18next"; - -export const i18n = await initI18Next(); - import { useEffect } from "react"; +import { i18n, registerTranslations } from "@repo/i18next"; +import { useTranslation as useI18NextTranslation } from "react-i18next"; import enResources from "./en.json"; import esResources from "./es.json"; @@ -19,16 +16,12 @@ const ensureAppTranslations = () => { * Hook de traducción específico de la app principal. */ export const useAppTranslation = () => { - // Hook base, sin namespace - const base = useI18NextTranslation(); - const { i18n } = base; - - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - // idempotente: solo añade si faltan bundles ensureAppTranslations(); }, [i18n]); - // Hook con namespace de la app - return useI18NextTranslation(APP_MODULE_NAME); + return useI18NextTranslation(APP_MODULE_NAME, { + i18n, + }); }; diff --git a/apps/web/src/pages/error-page.tsx b/apps/web/src/pages/error-page.tsx deleted file mode 100644 index ec3adf0c..00000000 --- a/apps/web/src/pages/error-page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from "@repo/shadcn-ui/components"; - -export const ErrorPage = () => { - return ( -
-
-
-

- Oops, page not found! -

-

- The page you're looking for doesn't exist or has been moved. -

-
- -
-
-
- ); -}; diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 34667041..f496d581 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -1,2 +1,3 @@ -export * from "./error-page"; export * from "./login-form"; +export * from "./render-error-fallback"; +export * from "./route-error-page"; diff --git a/apps/web/src/pages/render-error-fallback.test.tsx b/apps/web/src/pages/render-error-fallback.test.tsx new file mode 100644 index 00000000..58d45b61 --- /dev/null +++ b/apps/web/src/pages/render-error-fallback.test.tsx @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { RenderErrorFallback } from "./render-error-fallback"; + +test("renderiza un fallback de error inesperado sin detalles técnicos", () => { + const html = renderToStaticMarkup( + undefined} resetErrorBoundary={() => undefined} /> + ); + + assert.match(html, /Se ha producido un error inesperado/); + assert.match(html, /Intentar de nuevo/); + assert.match(html, /Ir al inicio/); + assert.doesNotMatch(html, /stack/i); + assert.doesNotMatch(html, /Error:/); +}); diff --git a/apps/web/src/pages/render-error-fallback.tsx b/apps/web/src/pages/render-error-fallback.tsx new file mode 100644 index 00000000..f02042c2 --- /dev/null +++ b/apps/web/src/pages/render-error-fallback.tsx @@ -0,0 +1,35 @@ +import { PageErrorState } from "@erp/core/components"; +import type { FallbackProps } from "react-error-boundary"; + +import { useAppTranslation } from "../locales/i18n"; + +export interface RenderErrorFallbackProps extends Pick { + onGoHome: () => void; +} + +export const RenderErrorFallback = ({ + onGoHome, + resetErrorBoundary, +}: RenderErrorFallbackProps) => { + const { t } = useAppTranslation(); + + return ( +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/pages/route-error-page.tsx b/apps/web/src/pages/route-error-page.tsx new file mode 100644 index 00000000..a4c8d50d --- /dev/null +++ b/apps/web/src/pages/route-error-page.tsx @@ -0,0 +1,103 @@ +import { PageErrorState } from "@erp/core/components"; +import { useCallback } from "react"; +import { useNavigate, useRevalidator, useRouteError } from "react-router-dom"; + +import { useAppTranslation } from "../locales/i18n"; +import { + type RouteErrorPageVariant, + resolveRouteErrorPageViewModel, +} from "../routes/resolve-route-error-page-view-model"; + +interface RouteErrorPageProps { + variant?: RouteErrorPageVariant; +} + +const useRouteErrorActions = (canRetry: boolean) => { + const navigate = useNavigate(); + const revalidator = useRevalidator(); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const retry = useCallback(() => { + revalidator.revalidate(); + navigate(0); + }, [navigate, revalidator]); + + return { + goHome, + retry: canRetry ? retry : undefined, + }; +}; + +export const RouteErrorPage = ({ variant }: RouteErrorPageProps) => { + const { t } = useAppTranslation(); + const routeError = useRouteError(); + const viewModel = resolveRouteErrorPageViewModel({ + error: routeError, + variant, + translate: t, + }); + const actions = useRouteErrorActions(viewModel.canRetry); + + return ( +
+
+ +
+
+ ); +}; + +export const StaticRouteErrorPage = ({ variant }: Required) => { + const { t } = useAppTranslation(); + const viewModel = resolveRouteErrorPageViewModel({ + variant, + translate: t, + }); + const navigate = useNavigate(); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + return ( +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index 7cf79f78..ff0da0dc 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -3,13 +3,38 @@ import { Navigate, createBrowserRouter } from "react-router-dom"; import { buildModuleRoutes } from "@/components/module-routes"; -import { AppFullscreenLayout, AppSidebarLayout, AuthLayout } from "@/layout"; +import { + AppFullscreenLayout, + AppFullscreenLayoutFrame, + AppSidebarLayout, + AppSidebarLayoutFrame, + AuthLayout, + AuthLayoutFrame, +} from "@/layout"; import ShadcnShowcasePage from "@/pages/shadcn-ui-page"; import TailwindV4ShowcasePage from "@/pages/tailwindcss-page"; -import { ErrorPage } from "../pages"; +import { RouteErrorPage, StaticRouteErrorPage } from "../pages"; import { modules } from "../register-modules"; +const AuthRouteErrorElement = () => ( + + + +); + +const AppFullscreenRouteErrorElement = () => ( + + + +); + +const AppSidebarRouteErrorElement = () => ( + + + +); + export const getAppRouter = () => { const params = { ...import.meta.env, @@ -44,6 +69,7 @@ export const getAppRouter = () => { { element: , + errorElement: , children: authRoutes, }, @@ -63,11 +89,13 @@ export const getAppRouter = () => { { element: , + errorElement: , children: appFullscreenRoutes, }, { element: , + errorElement: , children: [ { path: "shadcnui", @@ -79,19 +107,19 @@ export const getAppRouter = () => { }, { path: "dashboard", - element: , + element: , }, { path: "settings", - element: , + element: , }, { path: "catalog", - element: , + element: , }, { path: "quotes", - element: , + element: , }, ...appSidebarRoutes, @@ -100,7 +128,7 @@ export const getAppRouter = () => { { path: "*", - element: , + element: , }, ], }, diff --git a/apps/web/src/routes/resolve-route-error-page-view-model.test.ts b/apps/web/src/routes/resolve-route-error-page-view-model.test.ts new file mode 100644 index 00000000..d2350cc4 --- /dev/null +++ b/apps/web/src/routes/resolve-route-error-page-view-model.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveRouteErrorPageViewModel } from "./resolve-route-error-page-view-model"; + +const translate = (key: string) => + ({ + "routeErrors.notFound.title": "Página no encontrada", + "routeErrors.notFound.description": "La dirección no existe o ha cambiado.", + "routeErrors.forbidden.title": "No tienes acceso a esta página", + "routeErrors.forbidden.description": + "No dispones de permisos para acceder a esta sección.", + "routeErrors.server.title": "No se pudo mostrar esta página", + "routeErrors.server.description": + "Se produjo un error interno mientras se cargaba la ruta.", + "routeErrors.unavailable.title": "El servicio no está disponible", + "routeErrors.unavailable.description": + "La sección que intentas abrir no está disponible en este momento.", + "routeErrors.unknown.title": "No se pudo mostrar esta página", + "routeErrors.unknown.description": + "Ha ocurrido un error inesperado mientras se cargaba la ruta.", + })[key] ?? key; + +test("path desconocido usa notFound sin retry", () => { + const viewModel = resolveRouteErrorPageViewModel({ + variant: "notFound", + translate, + }); + + assert.equal(viewModel.variant, "notFound"); + assert.equal(viewModel.canRetry, false); +}); + +test("403 route error usa forbidden sin retry", () => { + const viewModel = resolveRouteErrorPageViewModel({ + error: new Response(null, { status: 403 }), + translate, + }); + + assert.equal(viewModel.variant, "forbidden"); + assert.equal(viewModel.canRetry, false); +}); + +test("500 route error usa server con retry", () => { + const viewModel = resolveRouteErrorPageViewModel({ + error: new Response(null, { status: 500 }), + translate, + }); + + assert.equal(viewModel.variant, "server"); + assert.equal(viewModel.canRetry, true); +}); + +test("503 route error usa unavailable con retry", () => { + const viewModel = resolveRouteErrorPageViewModel({ + error: new Response(null, { status: 503 }), + translate, + }); + + assert.equal(viewModel.variant, "unavailable"); + assert.equal(viewModel.canRetry, true); +}); + +test("errores desconocidos usan unknown con retry", () => { + const viewModel = resolveRouteErrorPageViewModel({ + error: new Error("technical"), + translate, + }); + + assert.equal(viewModel.variant, "unknown"); + assert.equal(viewModel.canRetry, true); +}); diff --git a/apps/web/src/routes/resolve-route-error-page-view-model.ts b/apps/web/src/routes/resolve-route-error-page-view-model.ts new file mode 100644 index 00000000..ccaff389 --- /dev/null +++ b/apps/web/src/routes/resolve-route-error-page-view-model.ts @@ -0,0 +1,67 @@ +import { isRouteErrorResponse } from "react-router-dom"; + +import type { PageErrorStateVariant } from "@erp/core/components"; + +export type RouteErrorPageVariant = "notFound" | "forbidden" | "server" | "unavailable" | "unknown"; + +export interface RouteErrorPageViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; +} + +interface Translate { + (key: string): string; +} + +const normalizeVariantFromStatus = (status: number): RouteErrorPageVariant => { + if (status === 403 || status === 401) { + return "forbidden"; + } + + if (status === 404) { + return "notFound"; + } + + if (status === 503) { + return "unavailable"; + } + + if (status >= 500) { + return "server"; + } + + return "unknown"; +}; + +const getVariantFromUnknownError = (error: unknown): RouteErrorPageVariant => { + if (isRouteErrorResponse(error)) { + return normalizeVariantFromStatus(error.status); + } + + if (error instanceof Response) { + return normalizeVariantFromStatus(error.status); + } + + return "unknown"; +}; + +export const resolveRouteErrorPageViewModel = ({ + error, + variant, + translate, +}: { + error?: unknown; + variant?: RouteErrorPageVariant; + translate: Translate; +}): RouteErrorPageViewModel => { + const resolvedVariant = variant ?? getVariantFromUnknownError(error); + + return { + variant: resolvedVariant, + title: translate(`routeErrors.${resolvedVariant}.title`), + description: translate(`routeErrors.${resolvedVariant}.description`), + canRetry: ["server", "unavailable", "unknown"].includes(resolvedVariant), + }; +}; diff --git a/docs/README.md b/docs/README.md index 9b6f30b5..d04573e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. - [Arquitectura general](./architecture/README.md) - [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md) +- [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md) - [API de autenticación de `identity`](./architecture/identity-auth-api.md) - [Relación entre `identity` y `companies`](./architecture/identity-and-companies.md) - [Estado de migración a `identity`](./architecture/identity-migration-status.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 26e89805..13db8902 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -7,6 +7,7 @@ - [Identity and companies](./identity-and-companies.md) - [Tenant context](./tenant-context.md) - [Identity auth API](./identity-auth-api.md) +- [Tratamiento de errores HTTP en frontend](./http-error-handling-frontend.md) - [Identity migration status](./identity-migration-status.md) - [Identity roadmap](./identity-roadmap.md) diff --git a/docs/architecture/http-error-handling-frontend.md b/docs/architecture/http-error-handling-frontend.md new file mode 100644 index 00000000..6f7a562a --- /dev/null +++ b/docs/architecture/http-error-handling-frontend.md @@ -0,0 +1,361 @@ +# Tratamiento de errores HTTP en Frontend ERP + +## 1. Objetivo + +Cualquier error que cruce `IDataSource` desde el frontend ERP queda normalizado como `ApiClientError`. + +Esto evita que los módulos consumidores dependan de `AxiosError` o inspeccionen `response.data` directamente. + +## 2. Flujo + +```txt +Axios +→ interceptor +→ IDataSource +→ normalizeAxiosError +→ ApiClientError +→ TanStack Query +→ controller +→ UI +``` + +## 3. Responsabilidades + +```txt +setupInterceptors + → auth, headers y refresh + +normalizeAxiosError + → normalización HTTP/transporte + +ApiClientError + → contrato transversal + +TanStack Query + → transporte del error al consumidor + +Controller/Page Controller + → decisión UX + +UI + → render +``` + +## 4. Clasificación + +| Caso | kind | +| ---- | ---- | +| 400 | `badRequest` | +| 401 | `unauthorized` | +| 403 | `forbidden` | +| 404 | `notFound` | +| 409 | `conflict` | +| 422 | `validation` | +| 429 | `rateLimited` | +| 500-599 | `server` / `unavailable` | +| red | `network` | +| timeout | `timeout` | +| cancelación | `cancelled` | +| resto | `unknown` | + +## 5. Problem Details + +El frontend soporta Problem Details parciales y completos. + +Campos reconocidos: + +```txt +type +title +status +detail +instance +method +correlationId +code +source +errors +``` + +`errors` puede estar ausente, vacío o contener elementos parciales válidos. + +## 6. Mensajes + +```txt +ApiClientError.message + → diagnóstico técnico estable + +getUserErrorMessage + → mensaje seguro genérico + +controller/page controller + → mensaje específico del caso de uso +``` + +`ApiClientError.message` no debe tratarse como texto UX por defecto. + +## 7. Reglas para nuevos módulos + +```txt +No comprobar AxiosError en controllers. +No leer error.response.data en UI. +No normalizar errores dentro de hooks. +No mostrar problem.detail automáticamente. +No crear clases de error HTTP por módulo. +No usar error.message como mensaje UX por defecto. +``` + +## 8. Ejemplo de consumo + +```ts +if (isApiClientError(error) && error.kind === "notFound") { + // El controller decide el estado específico del recurso. +} +``` + +## 9. Niveles de error + +La presentación distingue cuatro niveles: + +```txt +1. Error de validación/formulario +2. Error inline o de mutation +3. Error bloqueante de página +4. Error inesperado de routing/render +``` + +Regla crítica: + +```txt +PageErrorState + no conoce Axios + no conoce TanStack Query + no conoce React Router + no conoce ErrorBoundary +``` + +## 10. Decisiones no adoptadas + +En esta fase no se introduce: + +```txt +ErrorProvider global +toasts globales automáticos +framework de errores no HTTP +registro dinámico de errores +``` + +## 11. Estados visuales bloqueantes + +Para errores que impiden renderizar una página completa, la UI debe usar un view model explícito y un componente dedicado de página. + +```txt +ApiClientError +→ builder específico del caso de uso +→ PageErrorViewModel +→ Page Controller +→ PageErrorState +``` + +Reglas: + +```txt +ErrorAlert + → error inline y no bloqueante + +PageErrorState + → error que impide renderizar la página + +componentes locales + → errores embebidos en una subárea, como PDFViewer +``` + +`NotFoundCard` queda retirado. Su sustitución es: + +```txt +404 real + → PageErrorState variant="notFound" + +success sin entidad válida + → PageErrorState variant="unknown" + +colección vacía + → empty state +``` + +## 12. Listados paginados + +En listados con `placeholderData`, el estado visual debe distinguir entre fallo bloqueante y error de refetch: + +```txt +error inicial + sin data utilizable + → PageErrorState + +error durante refetch + data previa utilizable + → mantener tabla + → ErrorAlert inline +``` + +Reglas aplicadas en proformas, customers e issued-invoices: + +```txt +cancelled + → sin feedback visual + +notFound en listados + → no se interpreta como empty + → se trata como error remoto anómalo + → mapeado a unknown en esta fase +``` + +## 13. Detalle y edición de recursos + +En pantallas de detalle o edición de una entidad: + +```txt +ApiClientError.kind === notFound + → ausencia real del recurso + → PageErrorState específico de notFound + +respuesta exitosa sin entidad válida + → inconsistencia del contrato + → variant unknown + +cancelled + → no renderiza pantalla bloqueante +``` + +La decisión no debe derivarse de `!entity` a secas. El page controller debe distinguir entre: + +```txt +error HTTP real +inconsistencia de datos +cancelación +``` + +El patrón se aplica ya en: + +```txt +proformas/detail +proformas/list +proformas/update +customers/list +customers/view +customers/update +issued-invoices/list +``` + +## 14. Queries HTTP + +Para queries normales, el flujo estable es: + +```txt +ApiClientError +→ builder específico del caso de uso +→ PageErrorState / ErrorAlert +``` + +Reglas: + +```txt +error inicial sin datos + → PageErrorState + +error de refetch con datos previos + → conservar contenido + → ErrorAlert inline + +mutation error + → ErrorAlert o feedback local + +cancelled + → sin pantalla de error +``` + +En `issued-invoices` solo existe actualmente el listado principal y la descarga de PDF. No hay detalle, create ni update cableados en web; por eso la migración de esta fase cubre: + +```txt +issued-invoices/list + → PageErrorState para fallo bloqueante + → ErrorAlert para refetch con datos previos + +issued-invoices/download-pdf + → feedback local mediante toast + → no promueve el error a pantalla completa +``` + +## 15. Routing + +Los errores del router no se interpretan como `ApiClientError`. + +Flujo: + +```txt +Route error +→ clasificador de app +→ RouteErrorPage +→ PageErrorState +``` + +Cobertura actual: + +```txt +404 +403 +500 +503 +unknown +``` + +La clasificación vive en `apps/web` y usa `errorElement` en las ramas del router que montan shell autenticado, fullscreen y auth. Las rutas placeholder antiguas (`dashboard`, `settings`, `catalog`, `quotes`) pasan a reutilizar la misma base visual de `PageErrorState`. + +## 16. Render inesperado + +Los errores inesperados de render de React quedan separados de Query y Routing. + +Flujo: + +```txt +React render error +→ ErrorBoundary +→ RenderErrorFallback +→ PageErrorState +``` + +Reglas: + +```txt +resetErrorBoundary() + → retry real del boundary + +Ir al inicio + → acción explícita + +no stack +no error.message +no problem.detail +``` + +## 17. Legacy + +Estado final de piezas legacy relacionadas: + +```txt +ErrorPage + → eliminado y sustituido por RouteErrorPage / StaticRouteErrorPage + +error-fallbacks + → eliminado + +ErrorOverlay + → eliminado por ausencia de consumidores + +NotFoundCard + → eliminado globalmente + +ErrorState + → se mantiene como compatibilidad sobre PageErrorState + +ErrorAlert + → se mantiene para feedback inline no destructivo +``` diff --git a/modules/core/src/web/components/__tests__/page-error-state.test.tsx b/modules/core/src/web/components/__tests__/page-error-state.test.tsx new file mode 100644 index 00000000..c2e4c362 --- /dev/null +++ b/modules/core/src/web/components/__tests__/page-error-state.test.tsx @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PageErrorState } from "../page-error-state"; + +test("renderiza el estado de error con acciones y referencia", () => { + const html = renderToStaticMarkup( + undefined, + }} + reference="corr-123" + secondaryAction={{ + label: "Volver al listado", + onClick: () => undefined, + }} + title="No se pudo cargar la proforma" + variant="server" + /> + ); + + assert.match(html, /No se pudo cargar la proforma/); + assert.match(html, /Reintentar/); + assert.match(html, /Volver al listado/); + assert.match(html, /corr-123/); + assert.match(html, /data-variant="server"/); +}); + +test("omite acciones y referencia cuando no existen", () => { + const html = renderToStaticMarkup( + + ); + + assert.match(html, /Proforma no encontrada/); + assert.doesNotMatch(html, /button/); + assert.doesNotMatch(html, /Referencia:/); +}); diff --git a/modules/core/src/web/components/error-state.tsx b/modules/core/src/web/components/error-state.tsx index 6a5a51b5..8b1d5f51 100644 --- a/modules/core/src/web/components/error-state.tsx +++ b/modules/core/src/web/components/error-state.tsx @@ -1,9 +1,5 @@ -// packages/rdx-ui/src/components/error-state.tsx - -import { Button } from "@repo/shadcn-ui/components"; -import { cn } from "@repo/shadcn-ui/lib/utils"; -import { AlertCircle, RefreshCw } from "lucide-react"; import type React from "react"; +import { PageErrorState } from "./page-error-state"; export interface ErrorStateProps extends Omit, "title"> { title: React.ReactNode; @@ -25,50 +21,22 @@ export const ErrorState = ({ ...props }: ErrorStateProps) => { return ( -
- - -

{title}

- - {description ? ( -
{description}
- ) : null} - - {reference ? ( -

- Referencia:{" "} - - {reference} - -

- ) : null} - - {onRetry ? ( - - ) : null} -
+ /> ); }; diff --git a/modules/core/src/web/components/index.ts b/modules/core/src/web/components/index.ts index 8db2b51d..1e089680 100644 --- a/modules/core/src/web/components/index.ts +++ b/modules/core/src/web/components/index.ts @@ -2,5 +2,5 @@ export * from "./empty-cell-value"; export * from "./error-alert"; export * from "./error-state"; export * from "./form"; -export * from "./not-found-card"; export * from "./page-header"; +export * from "./page-error-state"; diff --git a/modules/core/src/web/components/not-found-card.tsx b/modules/core/src/web/components/not-found-card.tsx deleted file mode 100644 index 3d33f654..00000000 --- a/modules/core/src/web/components/not-found-card.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// components/NotFoundCard.tsx -import { BackHistoryButton } from "@repo/rdx-ui/components"; - -interface NotFoundCardProps { - title: string; - message: string; -} - -export const NotFoundCard = ({ title, message }: NotFoundCardProps) => ( - <> -
-

{title}

-

{message}

-
-
- -
- -); diff --git a/modules/core/src/web/components/page-error-state.tsx b/modules/core/src/web/components/page-error-state.tsx new file mode 100644 index 00000000..6ec312ea --- /dev/null +++ b/modules/core/src/web/components/page-error-state.tsx @@ -0,0 +1,103 @@ +import { Button } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { AlertCircle } from "lucide-react"; +import type React from "react"; + +export type PageErrorStateVariant = + | "notFound" + | "forbidden" + | "network" + | "timeout" + | "server" + | "unavailable" + | "unknown"; + +export interface PageErrorAction { + label: string; + onClick: () => void | Promise; + isLoading?: boolean; +} + +export interface PageErrorStateProps extends Omit, "title"> { + variant: PageErrorStateVariant; + title: React.ReactNode; + description: React.ReactNode; + primaryAction?: PageErrorAction; + secondaryAction?: PageErrorAction; + reference?: string; +} + +const VARIANT_STYLES: Record = { + notFound: "border-border bg-muted/30", + forbidden: "border-destructive/30 bg-destructive/5", + network: "border-destructive/30 bg-destructive/5", + timeout: "border-destructive/30 bg-destructive/5", + server: "border-destructive/30 bg-destructive/5", + unavailable: "border-destructive/30 bg-destructive/5", + unknown: "border-destructive/30 bg-destructive/5", +}; + +export const PageErrorState = ({ + variant, + title, + description, + primaryAction, + secondaryAction, + reference, + className, + ...props +}: PageErrorStateProps) => { + return ( +
+ + +

{title}

+
{description}
+ + {reference ? ( +

+ Referencia:{" "} + + {reference} + +

+ ) : null} + + {primaryAction || secondaryAction ? ( +
+ {primaryAction ? ( + + ) : null} + + {secondaryAction ? ( + + ) : null} +
+ ) : null} +
+ ); +}; diff --git a/modules/core/src/web/lib/data-source/axios/__tests__/normalize-axios-error.test.ts b/modules/core/src/web/lib/data-source/axios/__tests__/normalize-axios-error.test.ts new file mode 100644 index 00000000..7a95823c --- /dev/null +++ b/modules/core/src/web/lib/data-source/axios/__tests__/normalize-axios-error.test.ts @@ -0,0 +1,246 @@ +import { AxiosError, AxiosHeaders } from "axios"; +import type { AxiosResponse, InternalAxiosRequestConfig } from "axios"; + +import { isApiClientError } from "../../errors"; + +import { normalizeAxiosError } from "../normalize-axios-error"; + +const createAxiosConfig = (): InternalAxiosRequestConfig => { + return { + headers: new AxiosHeaders(), + method: "get", + url: "/resource", + } as InternalAxiosRequestConfig; +}; + +const createAxiosResponse = (status: number, data: TData): AxiosResponse => { + return { + config: createAxiosConfig(), + data, + headers: {}, + request: {}, + status, + statusText: `${status}`, + }; +}; + +const createAxiosHttpError = ( + status: number, + data: TData, + code?: string +): AxiosError => { + return new AxiosError( + `Request failed with status code ${status}`, + code, + createAxiosConfig(), + {}, + createAxiosResponse(status, data) + ); +}; + +describe("normalizeAxiosError", () => { + test("normaliza un 400 Problem Details como badRequest", () => { + const error = createAxiosHttpError(400, { + detail: "Invalid request payload.", + status: 400, + title: "Bad Request", + }); + + const normalized = normalizeAxiosError(error); + + expect(normalized.kind).toBe("badRequest"); + expect(normalized.status).toBe(400); + expect(normalized.problem?.title).toBe("Bad Request"); + }); + + test("normaliza un 401 como unauthorized", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(401, { + detail: "Unauthorized.", + status: 401, + }) + ); + + expect(normalized.kind).toBe("unauthorized"); + expect(normalized.status).toBe(401); + }); + + test("normaliza un 403 como forbidden", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(403, { + detail: "Forbidden.", + status: 403, + }) + ); + + expect(normalized.kind).toBe("forbidden"); + expect(normalized.status).toBe(403); + }); + + test("normaliza un 404 completo como notFound y conserva Problem Details", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(404, { + detail: "Entity 'Proforma' with id '123' was not found.", + instance: "/api/v1/proformas/123", + method: "GET", + status: 404, + title: "Resource Not Found", + type: "https://httpstatuses.com/404", + }) + ); + + expect(normalized.kind).toBe("notFound"); + expect(normalized.status).toBe(404); + expect(normalized.problem?.instance).toBe("/api/v1/proformas/123"); + expect(normalized.problem?.method).toBe("GET"); + }); + + test("normaliza un 404 parcial sin producir el mensaje false", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(404, { + detail: "Entity 'Proforma' with id '123' was not found.", + instance: "/api/v1/proformas/123", + method: "GET", + status: 404, + title: "Resource Not Found", + type: "https://httpstatuses.com/404", + }) + ); + + expect(normalized.kind).toBe("notFound"); + expect(normalized.status).toBe(404); + expect(normalized.message).not.toBe("false"); + expect(normalized.message).toBe("Entity 'Proforma' with id '123' was not found."); + }); + + test("normaliza un 409 como conflict", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(409, { + detail: "Conflict.", + status: 409, + }) + ); + + expect(normalized.kind).toBe("conflict"); + expect(normalized.status).toBe(409); + }); + + test("normaliza un 422 con errors como validation", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(422, { + errors: [{ message: "Field is required", path: ["name"] }], + status: 422, + title: "Validation Failed", + }) + ); + + expect(normalized.kind).toBe("validation"); + expect(normalized.status).toBe(422); + expect(normalized.issues).toEqual([{ message: "Field is required", path: ["name"] }]); + }); + + test("normaliza un 429 como rateLimited", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(429, { + detail: "Too many requests.", + status: 429, + }) + ); + + expect(normalized.kind).toBe("rateLimited"); + expect(normalized.status).toBe(429); + }); + + test("normaliza un 500 como server", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(500, { + detail: "Internal Server Error.", + status: 500, + }) + ); + + expect(normalized.kind).toBe("server"); + expect(normalized.status).toBe(500); + }); + + test("normaliza un 503 como unavailable", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(503, { + detail: "Service unavailable.", + status: 503, + }) + ); + + expect(normalized.kind).toBe("unavailable"); + expect(normalized.status).toBe(503); + }); + + test("normaliza un status desconocido como unknown", () => { + const normalized = normalizeAxiosError( + createAxiosHttpError(418, { + detail: "I'm a teapot.", + status: 418, + }) + ); + + expect(normalized.kind).toBe("unknown"); + expect(normalized.status).toBe(418); + }); + + test("normaliza un error de red como network", () => { + const normalized = normalizeAxiosError( + new AxiosError("Network Error", AxiosError.ERR_NETWORK, createAxiosConfig()) + ); + + expect(normalized.kind).toBe("network"); + expect(normalized.message).toBe("The server could not be reached."); + }); + + test("normaliza un timeout como timeout", () => { + const normalized = normalizeAxiosError( + new AxiosError("timeout", AxiosError.ECONNABORTED, createAxiosConfig()) + ); + + expect(normalized.kind).toBe("timeout"); + expect(normalized.message).toBe("The request timed out."); + }); + + test("normaliza una cancelación como cancelled", () => { + const normalized = normalizeAxiosError( + new AxiosError("canceled", AxiosError.ERR_CANCELED, createAxiosConfig()) + ); + + expect(normalized.kind).toBe("cancelled"); + expect(normalized.message).toBe("The request was cancelled."); + }); + + test("normaliza un AxiosError sin response como unknown si no es de red ni timeout ni cancelación", () => { + const normalized = normalizeAxiosError( + new AxiosError("socket closed", "SOME_UNKNOWN_CODE", createAxiosConfig()) + ); + + expect(normalized.kind).toBe("unknown"); + expect(normalized.message).toBe("The server could not be reached."); + }); + + test("envuelve un Error normal como ApiClientError", () => { + const normalized = normalizeAxiosError(new Error("Boom")); + + expect(isApiClientError(normalized)).toBe(true); + expect(normalized.kind).toBe("unknown"); + expect(normalized.message).toBe("Boom"); + }); + + test("envuelve un string como ApiClientError", () => { + const normalized = normalizeAxiosError("boom"); + + expect(normalized.kind).toBe("unknown"); + expect(normalized.message).toBe("An unexpected error occurred."); + }); + + test("envuelve null, undefined y objetos arbitrarios como ApiClientError", () => { + expect(normalizeAxiosError(null).kind).toBe("unknown"); + expect(normalizeAxiosError(undefined).kind).toBe("unknown"); + expect(normalizeAxiosError({ foo: "bar" }).kind).toBe("unknown"); + }); +}); diff --git a/modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts b/modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts index a8443c00..02ed52ac 100644 --- a/modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts +++ b/modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts @@ -1,76 +1,119 @@ -// modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts - import axios, { AxiosError } from "axios"; +import type { AxiosError as AxiosErrorType } from "axios"; +import type { ApiProblemDetails } from "../errors"; import { ApiClientError, type ApiClientErrorKind, isApiProblemDetails, -} from "../../../../../../../apps/web/src/lib/data-source"; + isApiClientError, +} from "../errors"; -const getErrorKind = (status: number): ApiClientErrorKind => { +const getErrorKind = (status?: number): ApiClientErrorKind => { switch (status) { + case 400: + return "badRequest"; case 401: return "unauthorized"; case 403: return "forbidden"; case 404: - return "not-found"; + return "notFound"; case 409: return "conflict"; case 422: return "validation"; + case 429: + return "rateLimited"; case 503: return "unavailable"; default: - return status >= 500 ? "server" : "unknown"; + return typeof status === "number" && status >= 500 ? "server" : "unknown"; } }; -const normalizeProblemError = (error: AxiosError, data: unknown): ApiClientError => { - if (!isApiProblemDetails(data)) { - return new ApiClientError({ - kind: error.response?.status && error.response.status >= 500 ? "server" : "unknown", - status: error.response?.status, - message: "The server returned an unexpected error response.", - cause: error, - }); +const buildProblemMessage = (problem: ApiProblemDetails | undefined, fallback: string): string => { + const message = problem?.detail ?? problem?.title; + + if (typeof message === "string" && message.trim().length > 0) { + return message; } + return fallback; +}; + +const getFallbackHttpMessage = (status?: number): string => { + if (typeof status !== "number") { + return "The server returned an unexpected error response."; + } + + return `HTTP request failed with status ${status}.`; +}; + +const normalizeProblemDetails = ( + data: unknown, + responseStatus?: number +): ApiProblemDetails | undefined => { + if (!isApiProblemDetails(data)) { + return undefined; + } + + return { + ...data, + status: data.status ?? responseStatus, + }; +}; + +const normalizeProblemError = ( + error: AxiosErrorType, + problem: ApiProblemDetails | undefined +): ApiClientError => { + const status = problem?.status ?? error.response?.status; + return new ApiClientError({ - kind: getErrorKind(data.status), - status: data.status, - title: data.title, - detail: data.detail, - code: data.code, - source: data.source, - instance: data.instance, - correlationId: data.correlationId, - issues: data.errors, - problem: data, - message: data.detail || data.title, + kind: getErrorKind(status), + status, + title: problem?.title, + detail: problem?.detail, + code: problem?.code, + source: problem?.source, + instance: problem?.instance, + correlationId: problem?.correlationId, + issues: problem?.errors, + problem, + message: buildProblemMessage(problem, getFallbackHttpMessage(status)), cause: error, }); }; -export const normalizeAxiosError = (error: unknown): Error => { +export const normalizeAxiosError = (error: unknown): ApiClientError => { + if (isApiClientError(error)) { + return error; + } + if (!axios.isAxiosError(error)) { - return error instanceof Error - ? error - : new ApiClientError({ - kind: "unknown", - message: "An unexpected error occurred.", - cause: error, - }); + return new ApiClientError({ + kind: "unknown", + message: error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An unexpected error occurred.", + cause: error, + }); } if (error.code === AxiosError.ERR_CANCELED) { - return error; + return new ApiClientError({ + kind: "cancelled", + status: error.response?.status, + message: "The request was cancelled.", + cause: error, + }); } if (error.code === AxiosError.ECONNABORTED) { return new ApiClientError({ kind: "timeout", + status: error.response?.status, message: "The request timed out.", cause: error, }); @@ -78,11 +121,12 @@ export const normalizeAxiosError = (error: unknown): Error => { if (!error.response) { return new ApiClientError({ - kind: "network", + kind: error.code === AxiosError.ERR_NETWORK ? "network" : "unknown", message: "The server could not be reached.", cause: error, }); } - return normalizeProblemError(error, error.response.data); + const problem = normalizeProblemDetails(error.response.data, error.response.status); + return normalizeProblemError(error, problem); }; diff --git a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts index 2bd81c3c..e634554a 100644 --- a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts +++ b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts @@ -118,7 +118,7 @@ export const setupInterceptors = ( : null; if (error.response?.status !== 401 || !originalConfig) { - return Promise.reject(normalizeAxiosError(error)); + return Promise.reject(error); } if (!shouldAttemptRefresh(pathname)) { @@ -126,12 +126,12 @@ export const setupInterceptors = ( onAuthError?.(); } - return Promise.reject(normalizeAxiosError(error)); + return Promise.reject(error); } if (originalConfig._retry) { onAuthError?.(); - return Promise.reject(normalizeAxiosError(error)); + return Promise.reject(error); } originalConfig._retry = true; @@ -140,7 +140,7 @@ export const setupInterceptors = ( if (!nextAccessToken) { onAuthError?.(); - return Promise.reject(normalizeAxiosError(error)); + return Promise.reject(error); } originalConfig.headers = originalConfig.headers ?? {}; @@ -152,33 +152,3 @@ export const setupInterceptors = ( return axiosInstance; }; - -/** - * Normaliza errores de Axios en un objeto estándar de Error - * con propiedades extra opcionales (status, raw). - */ -function normalizeAxiosError(error: AxiosError): Error { - let normalizedError: Error; - - if (error.response?.data) { - const data: any = error.response.data; - - // Intentamos localizar mensaje en campos comunes - const msg = - data.message ?? - (Array.isArray(data.errors) && data.errors[0]?.msg) ?? - error.message ?? - "Unknown server error"; - - normalizedError = new Error(msg); - - // Añadimos metadatos útiles - (normalizedError as any).status = error.response.status; - (normalizedError as any).raw = data; - } else { - normalizedError = new Error(error.message || "Unknown network error"); - (normalizedError as any).status = error.response?.status ?? 0; - } - - return normalizedError; -} diff --git a/modules/core/src/web/lib/data-source/datasource.interface.ts b/modules/core/src/web/lib/data-source/datasource.interface.ts index d2d93405..86da0ce5 100644 --- a/modules/core/src/web/lib/data-source/datasource.interface.ts +++ b/modules/core/src/web/lib/data-source/datasource.interface.ts @@ -11,6 +11,12 @@ export interface ICustomParams { [key: string]: unknown; } +/** + * Abstracción de transporte HTTP del frontend. + * + * En runtime, cualquier error que cruce esta frontera debe llegar normalizado + * como `ApiClientError` por la implementación concreta del data source. + */ export interface IDataSource { getBaseUrl(): string; getList(resource: string, params?: Record): Promise; diff --git a/modules/core/src/web/lib/data-source/errors/__tests__/is-api-problem-details.test.ts b/modules/core/src/web/lib/data-source/errors/__tests__/is-api-problem-details.test.ts new file mode 100644 index 00000000..159316ff --- /dev/null +++ b/modules/core/src/web/lib/data-source/errors/__tests__/is-api-problem-details.test.ts @@ -0,0 +1,72 @@ +import { isApiProblemDetails } from "../is-api-problem-details"; + +describe("isApiProblemDetails", () => { + test("acepta un Problem Details completo", () => { + expect( + isApiProblemDetails({ + detail: "Not found", + instance: "/api/v1/proformas/123", + method: "GET", + status: 404, + title: "Resource Not Found", + type: "https://httpstatuses.com/404", + }) + ).toBe(true); + }); + + test("acepta un Problem Details parcial útil", () => { + expect( + isApiProblemDetails({ + detail: "Not found", + status: 404, + }) + ).toBe(true); + }); + + test("acepta validaciones 422 con errors vacíos", () => { + expect( + isApiProblemDetails({ + errors: [], + status: 422, + title: "Validation Failed", + }) + ).toBe(true); + }); + + test("acepta errors con shape parcial válido", () => { + expect( + isApiProblemDetails({ + errors: [{ code: "required", path: ["name"] }], + status: 422, + }) + ).toBe(true); + }); + + test("rechaza valores no objeto", () => { + expect(isApiProblemDetails(null)).toBe(false); + expect(isApiProblemDetails(undefined)).toBe(false); + expect(isApiProblemDetails("error")).toBe(false); + }); + + test("rechaza objetos arbitrarios sin campos Problem Details", () => { + expect(isApiProblemDetails({ foo: "bar" })).toBe(false); + }); + + test("rechaza campos conocidos con tipos inválidos", () => { + expect( + isApiProblemDetails({ + detail: 404, + status: "404", + }) + ).toBe(false); + }); + + test("rechaza errors con elementos inválidos", () => { + expect( + isApiProblemDetails({ + errors: [{ path: [Symbol("bad")] }], + status: 422, + }) + ).toBe(false); + }); +}); diff --git a/modules/core/src/web/lib/data-source/errors/api-client-error.ts b/modules/core/src/web/lib/data-source/errors/api-client-error.ts index edc4f892..759adf71 100644 --- a/modules/core/src/web/lib/data-source/errors/api-client-error.ts +++ b/modules/core/src/web/lib/data-source/errors/api-client-error.ts @@ -3,15 +3,18 @@ import type { ApiProblemDetails, ApiProblemIssue, ApiProblemSource } from "./api-problem-details"; export type ApiClientErrorKind = + | "badRequest" | "unauthorized" | "forbidden" - | "not-found" + | "notFound" | "conflict" | "validation" + | "rateLimited" | "unavailable" | "server" | "network" | "timeout" + | "cancelled" | "unknown"; export interface ApiClientErrorOptions { @@ -25,7 +28,7 @@ export interface ApiClientErrorOptions { source?: ApiProblemSource; instance?: string; correlationId?: string; - issues?: ApiProblemIssue[]; + issues?: readonly ApiProblemIssue[]; problem?: ApiProblemDetails; cause?: unknown; } diff --git a/modules/core/src/web/lib/data-source/errors/api-problem-details.ts b/modules/core/src/web/lib/data-source/errors/api-problem-details.ts index 88794fe5..bb5ccb96 100644 --- a/modules/core/src/web/lib/data-source/errors/api-problem-details.ts +++ b/modules/core/src/web/lib/data-source/errors/api-problem-details.ts @@ -1,9 +1,9 @@ export type ApiProblemPathSegment = string | number; export interface ApiProblemIssue { - path?: ApiProblemPathSegment[]; + path?: readonly ApiProblemPathSegment[]; code?: string; - message: string; + message?: string; origin?: string; format?: string; @@ -12,15 +12,15 @@ export interface ApiProblemIssue { export type ApiProblemSource = "request" | "response" | "domain" | "infrastructure"; export interface ApiProblemDetails { - type: string; - title: string; - status: number; - detail: string; + type?: string; + title?: string; + status?: number; + detail?: string; instance?: string; method?: string; correlationId?: string; code?: string; source?: ApiProblemSource; - errors?: ApiProblemIssue[]; + errors?: readonly ApiProblemIssue[]; } diff --git a/modules/core/src/web/lib/data-source/errors/get-user-error-message.ts b/modules/core/src/web/lib/data-source/errors/get-user-error-message.ts index c356f39a..1c1b4e54 100644 --- a/modules/core/src/web/lib/data-source/errors/get-user-error-message.ts +++ b/modules/core/src/web/lib/data-source/errors/get-user-error-message.ts @@ -17,6 +17,13 @@ export const getUserErrorMessage = (error: unknown): UserErrorMessage => { const reference = error.correlationId; switch (error.kind) { + case "badRequest": + return { + title: "No se ha podido completar la operación", + description: "Revisa los datos enviados e inténtalo de nuevo.", + reference, + }; + case "unauthorized": return { title: "La sesión no es válida", @@ -31,7 +38,7 @@ export const getUserErrorMessage = (error: unknown): UserErrorMessage => { reference, }; - case "not-found": + case "notFound": return { title: "Recurso no encontrado", description: "El recurso solicitado ya no existe o no está disponible.", @@ -56,8 +63,14 @@ export const getUserErrorMessage = (error: unknown): UserErrorMessage => { return { title: "No se han podido procesar los datos", - description: - "El servidor ha devuelto información inconsistente. Inténtalo de nuevo o comunica la incidencia.", + description: "Algunos datos enviados no son válidos o no se han podido procesar.", + reference, + }; + + case "rateLimited": + return { + title: "Demasiadas solicitudes", + description: "Espera unos instantes antes de volver a intentarlo.", reference, }; @@ -73,6 +86,12 @@ export const getUserErrorMessage = (error: unknown): UserErrorMessage => { description: "El servidor no ha respondido dentro del tiempo esperado.", }; + case "cancelled": + return { + title: "Operación cancelada", + description: "La solicitud se ha cancelado antes de completarse.", + }; + case "unavailable": return { title: "Servicio temporalmente no disponible", diff --git a/modules/core/src/web/lib/data-source/errors/is-api-problem-details.ts b/modules/core/src/web/lib/data-source/errors/is-api-problem-details.ts index e2c0056f..55593626 100644 --- a/modules/core/src/web/lib/data-source/errors/is-api-problem-details.ts +++ b/modules/core/src/web/lib/data-source/errors/is-api-problem-details.ts @@ -2,6 +2,7 @@ import type { ApiProblemDetails, ApiProblemIssue, ApiProblemPathSegment, + ApiProblemSource, } from "./api-problem-details"; const isRecord = (value: unknown): value is Record => @@ -10,8 +11,14 @@ const isRecord = (value: unknown): value is Record => const isPathSegment = (value: unknown): value is ApiProblemPathSegment => typeof value === "string" || typeof value === "number"; +const isProblemSource = (value: unknown): value is ApiProblemSource => + value === "request" || + value === "response" || + value === "domain" || + value === "infrastructure"; + const isApiProblemIssue = (value: unknown): value is ApiProblemIssue => { - if (!isRecord(value) || typeof value.message !== "string") { + if (!isRecord(value)) { return false; } @@ -19,6 +26,22 @@ const isApiProblemIssue = (value: unknown): value is ApiProblemIssue => { return false; } + if (value.code !== undefined && typeof value.code !== "string") { + return false; + } + + if (value.message !== undefined && typeof value.message !== "string") { + return false; + } + + if (value.origin !== undefined && typeof value.origin !== "string") { + return false; + } + + if (value.format !== undefined && typeof value.format !== "string") { + return false; + } + return true; }; @@ -27,21 +50,56 @@ export const isApiProblemDetails = (value: unknown): value is ApiProblemDetails return false; } - if ( - typeof value.type !== "string" || - typeof value.title !== "string" || - typeof value.status !== "number" || - typeof value.detail !== "string" - ) { + if (value.type !== undefined && typeof value.type !== "string") { return false; } - if ( - value.errors !== undefined && - !(Array.isArray(value.errors) && value.errors.every(isApiProblemIssue)) - ) { + if (value.title !== undefined && typeof value.title !== "string") { return false; } - return true; + if (value.status !== undefined && typeof value.status !== "number") { + return false; + } + + if (value.detail !== undefined && typeof value.detail !== "string") { + return false; + } + + if (value.instance !== undefined && typeof value.instance !== "string") { + return false; + } + + if (value.method !== undefined && typeof value.method !== "string") { + return false; + } + + if (value.correlationId !== undefined && typeof value.correlationId !== "string") { + return false; + } + + if (value.code !== undefined && typeof value.code !== "string") { + return false; + } + + if (value.source !== undefined && !isProblemSource(value.source)) { + return false; + } + + if (value.errors !== undefined && !(Array.isArray(value.errors) && value.errors.every(isApiProblemIssue))) { + return false; + } + + return ( + value.type !== undefined || + value.title !== undefined || + value.status !== undefined || + value.detail !== undefined || + value.instance !== undefined || + value.method !== undefined || + value.correlationId !== undefined || + value.code !== undefined || + value.source !== undefined || + value.errors !== undefined + ); }; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 3e77bc3c..b680e4b2 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -209,6 +209,41 @@ "fit": "Fit" } }, + "page_error": { + "actions": { + "retry": "Retry", + "back_to_list": "Back to list", + "go_back": "Go back" + }, + "notFound": { + "title": "Proforma not found", + "description": "The proforma no longer exists or is not available." + }, + "forbidden": { + "title": "You do not have access to this proforma", + "description": "You do not have permission to view this document." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "Unable to load the proforma", + "description": "An internal error occurred while retrieving the proforma." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The proforma service is not available right now." + }, + "unknown": { + "title": "Unable to load the proforma", + "description": "An unexpected error occurred while loading the proforma." + } + }, "regions": { "document": "Document", "details": "Details" @@ -323,6 +358,9 @@ "unarchive": "Unarchive", "download_pdf": "Download PDF", "delete": "Delete", + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home", "view_linked_invoice": "View linked invoice", "disabled_reasons": { "archived_scope": "This action is not available from the archived proformas list.", @@ -339,6 +377,38 @@ "archived": "There are no archived proformas.", "deleted": "There are no deleted proformas." }, + "errors": { + "refresh": { + "title": "The list could not be refreshed", + "message": "The latest available data is shown while you try again." + }, + "page": { + "forbidden": { + "title": "You do not have access to the proformas list", + "description": "You do not have permission to view this list." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "Unable to load the proformas list", + "description": "An internal error occurred while retrieving the proformas." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The proformas service is not available right now." + }, + "unknown": { + "title": "Unable to load the proformas list", + "description": "An unexpected error occurred while loading the list." + } + } + }, "filters": { "status": { "label": "Status" @@ -386,7 +456,47 @@ "not_found_msg": "Check the identifier or go back to the list", "not_found_title": "Proforma not found", "error_msg": "Check the data and try again.", - "error_title": "Unable to save the changes" + "error_title": "Unable to save the changes", + "page_error": { + "actions": { + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home", + "back_to_list": "Back to list" + }, + "notFound": { + "title": "Proforma not found", + "description": "The proforma no longer exists or is not available." + }, + "forbidden": { + "title": "You do not have access to this proforma", + "description": "You do not have permission to edit this document." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "Unable to load the proforma", + "description": "An internal error occurred while retrieving the proforma." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The proforma service is not available right now." + }, + "unknown": { + "title": "Unable to load the proforma", + "description": "An unexpected error occurred while loading the proforma." + }, + "missing_entity": { + "title": "Unable to load the proforma", + "description": "The response does not contain a valid proforma." + } + } }, "delete": { "title": "Delete customer proforma", @@ -405,6 +515,44 @@ "list": { "title": "Customer invoices", "description": "List all customer invoices", + "search_placeholder": "Search by number, customer or TIN...", + "actions": { + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home" + }, + "errors": { + "refresh": { + "title": "The list could not be refreshed", + "message": "The latest available data is shown while you try again." + }, + "page": { + "forbidden": { + "title": "You do not have access to the invoices list", + "description": "You do not have permission to view this list." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "Unable to load the invoices list", + "description": "An internal error occurred while retrieving the invoices." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The invoicing service is not available right now." + }, + "unknown": { + "title": "Unable to load the invoices list", + "description": "An unexpected error occurred while loading the list." + } + } + }, "grid_columns": { "series_invoice_number": "#", "verifactu_status": "Status", @@ -590,4 +738,4 @@ } } } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 401a7fe8..c3ff060c 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -210,6 +210,41 @@ "fit": "Ajustar" } }, + "page_error": { + "actions": { + "retry": "Reintentar", + "back_to_list": "Volver al listado", + "go_back": "Volver" + }, + "notFound": { + "title": "Proforma no encontrada", + "description": "La proforma ya no existe o no está disponible." + }, + "forbidden": { + "title": "No tienes acceso a esta proforma", + "description": "No dispones de permisos para consultar este documento." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar la proforma", + "description": "Se produjo un error interno mientras se recuperaba la proforma." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de proformas no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar la proforma", + "description": "Ha ocurrido un error inesperado al cargar la proforma." + } + }, "regions": { "document": "Documento", "details": "Detalles" @@ -324,6 +359,9 @@ "unarchive": "Desarchivar", "download_pdf": "Descargar PDF", "delete": "Eliminar", + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio", "view_linked_invoice": "Ver factura vinculada", "disabled_reasons": { "archived_scope": "No se puede ejecutar desde el listado de proformas archivadas.", @@ -340,6 +378,38 @@ "archived": "No hay proformas archivadas.", "deleted": "No hay proformas eliminadas." }, + "errors": { + "refresh": { + "title": "No se pudo actualizar el listado", + "message": "Se muestran los últimos datos disponibles mientras vuelves a intentarlo." + }, + "page": { + "forbidden": { + "title": "No tienes acceso al listado de proformas", + "description": "No dispones de permisos para consultar este listado." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar el listado de proformas", + "description": "Se produjo un error interno al recuperar las proformas." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de proformas no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar el listado de proformas", + "description": "Ha ocurrido un error inesperado al cargar el listado." + } + } + }, "filters": { "status": { "label": "Estado" @@ -387,7 +457,47 @@ "not_found_msg": "Revisa el identificador o vuelve al listado", "not_found_title": "Proforma no encontrada", "error_msg": "Revisa los datos e inténtalo de nuevo.", - "error_title": "No se pudo guardar los cambios" + "error_title": "No se pudo guardar los cambios", + "page_error": { + "actions": { + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio", + "back_to_list": "Volver al listado" + }, + "notFound": { + "title": "Proforma no encontrada", + "description": "La proforma ya no existe o no está disponible." + }, + "forbidden": { + "title": "No tienes acceso a esta proforma", + "description": "No dispones de permisos para editar este documento." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar la proforma", + "description": "Se produjo un error interno al recuperar la proforma." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de proformas no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar la proforma", + "description": "Ha ocurrido un error inesperado al cargar la proforma." + }, + "missing_entity": { + "title": "No se pudo cargar la proforma", + "description": "La respuesta recibida no contiene una proforma válida." + } + } }, "delete": { "title": "Eliminar proforma", @@ -406,6 +516,44 @@ "list": { "title": "Facturas de cliente", "description": "Lista todas las facturas de cliente", + "search_placeholder": "Buscar por número, cliente o NIF/CIF...", + "actions": { + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio" + }, + "errors": { + "refresh": { + "title": "No se pudo actualizar el listado", + "message": "Se muestran los últimos datos disponibles mientras vuelves a intentarlo." + }, + "page": { + "forbidden": { + "title": "No tienes acceso al listado de facturas", + "description": "No dispones de permisos para consultar este listado." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar el listado de facturas", + "description": "Se produjo un error interno al recuperar las facturas." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de facturación no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar el listado de facturas", + "description": "Ha ocurrido un error inesperado al cargar el listado." + } + } + }, "grid_columns": { "series_invoice_number": "#", "verifactu_status": "Estado", @@ -582,4 +730,4 @@ } } } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts index 62097f9e..1256bff5 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts @@ -1,11 +1,20 @@ -import React from "react"; +import React, { useCallback, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "../../../../i18n"; import { useDownloadInvoicePDFController } from "../../download-pdf/controller"; import type { IssuedInvoiceListRow } from "../../shared"; +import { + buildIssuedInvoiceListPageErrorViewModel, + buildIssuedInvoiceListRefreshErrorViewModel, +} from "../utils/build-issued-invoice-list-page-error-view-model"; +import { resolveIssuedInvoiceListFeedbackState } from "../utils/resolve-issued-invoice-list-feedback-state"; import { useIssuedInvoiceListController } from "./use-issued-invoice-list.controller"; export function useIssuedInvoiceListPageController() { + const { t } = useTranslation(); + const navigate = useNavigate(); const listCtrl = useIssuedInvoiceListController(); const downloadPDFCtrl = useDownloadInvoicePDFController(); @@ -16,8 +25,41 @@ export function useIssuedInvoiceListPageController() { [downloadPDFCtrl] ); + const feedbackState = resolveIssuedInvoiceListFeedbackState(listCtrl); + + const retryList = useCallback(async () => { + await listCtrl.refetch(); + }, [listCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const pageError = useMemo(() => { + if (!feedbackState.hasBlockingError) { + return null; + } + + return buildIssuedInvoiceListPageErrorViewModel(listCtrl.error, (key) => t(key)); + }, [feedbackState.hasBlockingError, listCtrl.error, t]); + + const refreshError = useMemo(() => { + if (!feedbackState.hasRefreshError) { + return null; + } + + return buildIssuedInvoiceListRefreshErrorViewModel(listCtrl.error, (key) => t(key)); + }, [feedbackState.hasRefreshError, listCtrl.error, t]); + return { listCtrl, + pageError, + refreshError, + hasBlockingError: feedbackState.hasBlockingError && pageError !== null, + hasRefreshError: feedbackState.hasRefreshError && refreshError !== null, + retryList, + goHome, + isRetrying: listCtrl.isFetching, downloadPDFCtrl, diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts index 3a2d6236..bc057005 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts @@ -1,4 +1,3 @@ -import { getUserErrorMessage } from "@erp/core/client"; import { useDataSource, useDebounce } from "@erp/core/hooks"; import { useQueries } from "@tanstack/react-query"; import { useMemo, useState } from "react"; @@ -92,7 +91,6 @@ export const useIssuedInvoiceListController = () => { })), }); - const errorMessage = query.error ? getUserErrorMessage(query.error) : null; const statusCounts = useMemo(() => { const counts = { ...EMPTY_ISSUED_INVOICE_STATUS_COUNTS, @@ -144,17 +142,18 @@ export const useIssuedInvoiceListController = () => { }; return { + queryData: query.data, data: { ...(query.data ?? EMPTY_ISSUED_INVOICES_LIST), statusCounts, }, isLoading: query.isLoading, isFetching: query.isFetching, + hasUsableData: query.data !== undefined, isError: query.isError, error: query.error, - errorMessage, - retry: query.refetch, + refetch: query.refetch, pageIndex, pageSize, diff --git a/modules/customer-invoices/src/web/issued-invoices/list/entities/index.ts b/modules/customer-invoices/src/web/issued-invoices/list/entities/index.ts new file mode 100644 index 00000000..63eb460c --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/entities/index.ts @@ -0,0 +1 @@ +export * from "./issued-invoice-list-page-error-view-model.entity"; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/entities/issued-invoice-list-page-error-view-model.entity.ts b/modules/customer-invoices/src/web/issued-invoices/list/entities/issued-invoice-list-page-error-view-model.entity.ts new file mode 100644 index 00000000..98205e3b --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/entities/issued-invoice-list-page-error-view-model.entity.ts @@ -0,0 +1,14 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface IssuedInvoiceListPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} + +export interface IssuedInvoiceListRefreshErrorViewModel { + title: string; + message: string; +} diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx index a9bdc52d..9bbab5af 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx @@ -1,4 +1,4 @@ -import { ErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components"; +import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { LogoVerifactu } from "@repo/rdx-ui/components"; import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; import { PlusIcon } from "lucide-react"; @@ -20,6 +20,13 @@ export const ListIssuedInvoicesPage = () => { const { listCtrl, + pageError, + refreshError, + hasBlockingError, + hasRefreshError, + retryList, + goHome, + isRetrying, handleDownloadPDF, isPDFDownloading: isPdfDownloading, pdfDownloadingId, @@ -39,24 +46,41 @@ export const ListIssuedInvoicesPage = () => { widthClass: "w-[500px]", });*/ - if (listCtrl.isError && listCtrl.errorMessage) { + if (hasBlockingError && pageError) { return ( - +
+
+ +
+
); - /*return ( - - - - - );*/ } return ( @@ -77,6 +101,10 @@ export const ListIssuedInvoicesPage = () => { />
+ {hasRefreshError && refreshError ? ( + + ) : null} +
¡Atención! @@ -110,7 +138,7 @@ export const ListIssuedInvoicesPage = () => { loading={listCtrl.isLoading} onPendingChange={setIsSearchPending} onSearchChange={listCtrl.setSearchValue} - placeholder="Buscar por numero, cliente o CIF..." + placeholder={t("pages.issued_invoices.list.search_placeholder")} value={listCtrl.search} />
diff --git a/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.test.ts b/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.test.ts new file mode 100644 index 00000000..32ef17e3 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildIssuedInvoiceListPageErrorViewModel, + buildIssuedInvoiceListRefreshErrorViewModel, +} from "./build-issued-invoice-list-page-error-view-model"; + +const translate = (key: string) => + ({ + "pages.issued_invoices.list.errors.page.forbidden.title": + "No tienes acceso al listado de facturas", + "pages.issued_invoices.list.errors.page.forbidden.description": + "No dispones de permisos para consultar este listado.", + "pages.issued_invoices.list.errors.page.network.title": + "No se pudo conectar con el servidor", + "pages.issued_invoices.list.errors.page.network.description": + "Comprueba tu conexión e inténtalo de nuevo.", + "pages.issued_invoices.list.errors.page.timeout.title": "La carga está tardando demasiado", + "pages.issued_invoices.list.errors.page.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "pages.issued_invoices.list.errors.page.server.title": + "No se pudo cargar el listado de facturas", + "pages.issued_invoices.list.errors.page.server.description": + "Se produjo un error interno al recuperar las facturas.", + "pages.issued_invoices.list.errors.page.unavailable.title": + "Servicio temporalmente no disponible", + "pages.issued_invoices.list.errors.page.unavailable.description": + "El servicio de facturación no está disponible en este momento.", + "pages.issued_invoices.list.errors.page.unknown.title": + "No se pudo cargar el listado de facturas", + "pages.issued_invoices.list.errors.page.unknown.description": + "Ha ocurrido un error inesperado al cargar el listado.", + "pages.issued_invoices.list.errors.refresh.title": + "No se pudo actualizar el listado", + "pages.issued_invoices.list.errors.refresh.message": + "Se muestran los últimos datos disponibles mientras vuelves a intentarlo.", + })[key] ?? key; + +test("mapea forbidden sin retry", () => { + const viewModel = buildIssuedInvoiceListPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical", correlationId: "corr-403" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-403"); +}); + +test("mapea network, timeout, server, unavailable y unknown con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) { + const viewModel = buildIssuedInvoiceListPageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("notFound de listado se degrada a unknown", () => { + const viewModel = buildIssuedInvoiceListPageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, "unknown"); + assert.equal(viewModel?.canRetry, true); +}); + +test("cancelled no genera estado de error bloqueante ni refresh alert", () => { + const blocking = buildIssuedInvoiceListPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + const refresh = buildIssuedInvoiceListRefreshErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(blocking, null); + assert.equal(refresh, null); +}); + +test("el refresh error mantiene copy inline genérico y no expone error técnico", () => { + const refresh = buildIssuedInvoiceListRefreshErrorViewModel( + new ApiClientError({ kind: "server", message: "technical" }), + translate + ); + + assert.deepEqual(refresh, { + title: "No se pudo actualizar el listado", + message: "Se muestran los últimos datos disponibles mientras vuelves a intentarlo.", + }); +}); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.ts b/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.ts new file mode 100644 index 00000000..e894e9eb --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/utils/build-issued-invoice-list-page-error-view-model.ts @@ -0,0 +1,70 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { + IssuedInvoiceListPageErrorViewModel, + IssuedInvoiceListRefreshErrorViewModel, +} from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildVariant = ( + error: unknown +): IssuedInvoiceListPageErrorViewModel["variant"] | null => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return null; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + case "notFound": + return "unknown"; + default: + return "unknown"; + } +}; + +export const buildIssuedInvoiceListPageErrorViewModel = ( + error: unknown, + translate: Translate +): IssuedInvoiceListPageErrorViewModel | null => { + const variant = buildVariant(error); + + if (!variant) { + return null; + } + + return { + variant, + title: translate(`pages.issued_invoices.list.errors.page.${variant}.title`), + description: translate(`pages.issued_invoices.list.errors.page.${variant}.description`), + canRetry: variant !== "forbidden", + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildIssuedInvoiceListRefreshErrorViewModel = ( + error: unknown, + translate: Translate +): IssuedInvoiceListRefreshErrorViewModel | null => { + if (isApiClientError(error) && error.kind === "cancelled") { + return null; + } + + return { + title: translate("pages.issued_invoices.list.errors.refresh.title"), + message: translate("pages.issued_invoices.list.errors.refresh.message"), + }; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.test.ts b/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.test.ts new file mode 100644 index 00000000..dbd99305 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveIssuedInvoiceListFeedbackState } from "./resolve-issued-invoice-list-feedback-state"; + +test("marca error bloqueante cuando falla la carga inicial sin datos utilizables", () => { + assert.deepEqual( + resolveIssuedInvoiceListFeedbackState({ + isError: true, + hasUsableData: false, + }), + { + hasBlockingError: true, + hasRefreshError: false, + } + ); +}); + +test("marca refresh error cuando falla un refetch conservando datos previos", () => { + assert.deepEqual( + resolveIssuedInvoiceListFeedbackState({ + isError: true, + hasUsableData: true, + }), + { + hasBlockingError: false, + hasRefreshError: true, + } + ); +}); + +test("no marca error cuando la query está estable", () => { + assert.deepEqual( + resolveIssuedInvoiceListFeedbackState({ + isError: false, + hasUsableData: true, + }), + { + hasBlockingError: false, + hasRefreshError: false, + } + ); +}); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.ts b/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.ts new file mode 100644 index 00000000..88666021 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/utils/resolve-issued-invoice-list-feedback-state.ts @@ -0,0 +1,19 @@ +interface ResolveIssuedInvoiceListFeedbackStateParams { + isError: boolean; + hasUsableData: boolean; +} + +export interface IssuedInvoiceListFeedbackState { + hasBlockingError: boolean; + hasRefreshError: boolean; +} + +export const resolveIssuedInvoiceListFeedbackState = ({ + isError, + hasUsableData, +}: ResolveIssuedInvoiceListFeedbackStateParams): IssuedInvoiceListFeedbackState => { + return { + hasBlockingError: isError && !hasUsableData, + hasRefreshError: isError && hasUsableData, + }; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts index 5655a197..3b5300b4 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts @@ -36,7 +36,7 @@ export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesListQueryOptio if ( error.kind === "validation" || error.kind === "forbidden" || - error.kind === "not-found" || + error.kind === "notFound" || error.kind === "conflict" ) { return false; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index 6e9e2d82..e061061e 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -32,10 +32,12 @@ import type { ProformaDetailPageState, } from "../entities"; import { + buildMissingProformaDetailPageErrorViewModel, buildProformaDetailActions, buildProformaDetailDocumentViewModel, buildProformaDetailGeneralViewModel, buildProformaDetailHeaderViewModel, + buildProformaDetailPageErrorViewModel, } from "../utils"; const PROFORMAS_FALLBACK_PATH = "/proformas"; @@ -155,16 +157,53 @@ export const useProformaDetailPageController = () => { [paymentMethodLabel, paymentTermLabel, t] ); + const handleOpenList = () => { + navigate(PROFORMAS_FALLBACK_PATH); + }; + + const handleNavigateBack = () => { + navigateBack(); + }; + + const handleRetryPage = async () => { + await proformaQuery.refetch(); + }; + + const pageErrorContext = useMemo( + () => ({ + translate: (key: string, params?: Record) => + t(key, params), + primaryAction: { + label: t("proformas.detail.page_error.actions.retry"), + onClick: handleRetryPage, + isLoading: proformaQuery.isFetching && !proformaQuery.isLoading, + }, + secondaryAction: { + label: t("proformas.detail.page_error.actions.back_to_list"), + onClick: handleOpenList, + }, + }), + [proformaQuery.isFetching, proformaQuery.isLoading, t] + ); + const state: ProformaDetailPageState = { isInitialLoading: proformaQuery.isLoading, isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading, - isError: proformaQuery.isError, - isNotFound: !(proformaQuery.isLoading || proformaQuery.isError || proforma), - errorMessage: proformaQuery.isError - ? proformaQuery.error instanceof Error - ? proformaQuery.error.message - : t("pages.proformas.update.load_error.message") - : null, + pageError: proformaQuery.isError + ? buildProformaDetailPageErrorViewModel(proformaQuery.error, pageErrorContext) + : !(proformaQuery.isLoading || proformaQuery.isError || proforma) + ? buildMissingProformaDetailPageErrorViewModel({ + translate: pageErrorContext.translate, + primaryAction: { + label: t("proformas.detail.page_error.actions.back_to_list"), + onClick: handleOpenList, + }, + secondaryAction: { + label: t("proformas.detail.page_error.actions.go_back"), + onClick: handleNavigateBack, + }, + }) + : null, }; const documentState: ProformaDetailDocumentState = { @@ -420,7 +459,7 @@ export const useProformaDetailPageController = () => { actions, navigation: { currentReturnTo, - navigateBack, + navigateBack: handleNavigateBack, openEdit: handleEdit, openCustomer: handleOpenCustomer, }, diff --git a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts index 5bcd5927..102dc405 100644 --- a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts @@ -1,11 +1,19 @@ +import type { PageErrorAction, PageErrorStateVariant } from "@erp/core/components"; import type { ProformaStatus } from "../../shared"; export interface ProformaDetailPageState { isInitialLoading: boolean; isRefetching: boolean; - isError: boolean; - isNotFound: boolean; - errorMessage: string | null; + pageError: ProformaDetailPageErrorViewModel | null; +} + +export interface ProformaDetailPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + primaryAction?: PageErrorAction; + secondaryAction?: PageErrorAction; + reference?: string; } export interface ProformaDetailHeaderViewModel { diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx index f8b44dc5..a96e49c1 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx @@ -1,6 +1,4 @@ -import { ErrorAlert, NotFoundCard } from "@erp/core/components"; - -import { useTranslation } from "../../../../i18n"; +import { PageErrorState } from "@erp/core/components"; import { ChangeProformaStatusDialog } from "../../../change-status"; import { IssueProformaDialog } from "../../../issue-proforma"; import { useProformaDetailPageController } from "../../controllers"; @@ -13,33 +11,23 @@ import { import { ProformaDetailSkeleton } from "../components"; export const ProformaDetailPage = () => { - const { t } = useTranslation(); const ctrl = useProformaDetailPageController(); if (ctrl.state.isInitialLoading) { return ; } - if (ctrl.state.isError) { - return ( -
-
- -
-
- ); - } - - if (ctrl.state.isNotFound || !(ctrl.header && ctrl.general && ctrl.document)) { + if (ctrl.state.pageError) { return (
-
diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-page-error-view-model.test.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-page-error-view-model.test.ts new file mode 100644 index 00000000..b34e7391 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-page-error-view-model.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildMissingProformaDetailPageErrorViewModel, + buildProformaDetailPageErrorViewModel, +} from "./build-proforma-detail-view-models"; + +const translate = (key: string) => + ({ + "proformas.detail.page_error.notFound.title": "Proforma no encontrada", + "proformas.detail.page_error.notFound.description": + "La proforma ya no existe o no está disponible.", + "proformas.detail.page_error.forbidden.title": "No tienes acceso a esta proforma", + "proformas.detail.page_error.forbidden.description": + "No dispones de permisos para consultar este documento.", + "proformas.detail.page_error.network.title": "No se pudo conectar con el servidor", + "proformas.detail.page_error.network.description": + "Comprueba tu conexión e inténtalo de nuevo.", + "proformas.detail.page_error.timeout.title": "La carga está tardando demasiado", + "proformas.detail.page_error.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "proformas.detail.page_error.server.title": "No se pudo cargar la proforma", + "proformas.detail.page_error.server.description": + "Se produjo un error interno mientras se recuperaba la proforma.", + "proformas.detail.page_error.unavailable.title": "Servicio temporalmente no disponible", + "proformas.detail.page_error.unavailable.description": + "El servicio de proformas no está disponible en este momento.", + "proformas.detail.page_error.unknown.title": "No se pudo cargar la proforma", + "proformas.detail.page_error.unknown.description": + "Ha ocurrido un error inesperado al cargar la proforma.", + })[key] ?? key; + +test("mapea un ApiClientError notFound al variant notFound y conserva correlationId", () => { + const viewModel = buildProformaDetailPageErrorViewModel( + new ApiClientError({ + kind: "notFound", + message: "technical not found", + correlationId: "corr-404", + }), + { + translate, + } + ); + + assert.equal(viewModel.variant, "notFound"); + assert.equal(viewModel.reference, "corr-404"); + assert.equal(viewModel.title, "Proforma no encontrada"); +}); + +test("mapea un error no reconocido al variant unknown sin exponer mensaje técnico", () => { + const viewModel = buildProformaDetailPageErrorViewModel(new Error("boom"), { + translate, + }); + + assert.equal(viewModel.variant, "unknown"); + assert.equal(viewModel.reference, undefined); + assert.equal(viewModel.description, "Ha ocurrido un error inesperado al cargar la proforma."); +}); + +test("genera un estado notFound para ausencia de datos sin depender de una excepción HTTP", () => { + const viewModel = buildMissingProformaDetailPageErrorViewModel({ + translate, + primaryAction: { + label: "Volver al listado", + onClick: () => undefined, + }, + }); + + assert.equal(viewModel.variant, "notFound"); + assert.equal(viewModel.primaryAction?.label, "Volver al listado"); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts index d2891ee1..2539d2b3 100644 --- a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts @@ -1,3 +1,4 @@ +import { isApiClientError } from "@erp/core/client"; import { DateHelper, MoneyHelper, NumberHelper } from "@repo/rdx-utils"; import type { Proforma } from "../../shared"; @@ -13,6 +14,7 @@ import type { ProformaDetailHeaderViewModel, ProformaDetailLinkedInvoiceState, ProformaDetailLinkedInvoiceViewModel, + ProformaDetailPageErrorViewModel, ProformaDetailSummaryViewModel, } from "../entities"; @@ -26,6 +28,12 @@ export interface BuildProformaDetailViewModelsContext { paymentTermLabel: string | null; } +export interface BuildProformaDetailPageErrorViewModelContext { + translate: ProformaDetailTranslate; + primaryAction?: ProformaDetailPageErrorViewModel["primaryAction"]; + secondaryAction?: ProformaDetailPageErrorViewModel["secondaryAction"]; +} + const formatMoney = (proforma: Proforma, value: number) => { return MoneyHelper.formatCurrency( value, @@ -105,6 +113,69 @@ const buildLinkedInvoiceState = (proforma: Proforma): ProformaDetailLinkedInvoic return "issued-linked-without-detail-route"; }; +const buildPageErrorCopy = ( + variant: ProformaDetailPageErrorViewModel["variant"], + translate: ProformaDetailTranslate +) => { + return { + title: translate(`proformas.detail.page_error.${variant}.title`), + description: translate(`proformas.detail.page_error.${variant}.description`), + }; +}; + +export const buildProformaDetailPageErrorViewModel = ( + error: unknown, + context: BuildProformaDetailPageErrorViewModelContext +): ProformaDetailPageErrorViewModel => { + const variant = (() => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } + })(); + + const copy = buildPageErrorCopy(variant, context.translate); + + return { + variant, + title: copy.title, + description: copy.description, + primaryAction: context.primaryAction, + secondaryAction: context.secondaryAction, + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildMissingProformaDetailPageErrorViewModel = ( + context: BuildProformaDetailPageErrorViewModelContext +): ProformaDetailPageErrorViewModel => { + const copy = buildPageErrorCopy("notFound", context.translate); + + return { + variant: "notFound", + title: copy.title, + description: copy.description, + primaryAction: context.primaryAction, + secondaryAction: context.secondaryAction, + }; +}; + export const buildProformaDetailHeaderViewModel = ( proforma: Proforma, context: BuildProformaDetailViewModelsContext diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts index b23c19dd..4135fb00 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts @@ -1,7 +1,7 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import type { RightPanelMode } from "@repo/rdx-ui/hooks"; -import { useCallback, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "../../../i18n"; import { useChangeProformaStatusDialogController } from "../../change-status"; @@ -15,12 +15,18 @@ import { useProformaUnarchiveMutation, } from "../../shared"; import type { ProformaListScope } from "../types/proforma-list-filters"; +import { + buildProformaListPageErrorViewModel, + buildProformaListRefreshErrorViewModel, +} from "../utils/build-proforma-list-page-error-view-model"; +import { resolveProformaListFeedbackState } from "../utils/resolve-proforma-list-feedback-state"; import { useListProformasController } from "./use-list-proformas.controller"; import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller"; export const useListProformasPageController = (scope: ProformaListScope) => { const { t } = useTranslation(); + const navigate = useNavigate(); const listCtrl = useListProformasController(scope); const deleteDialogCtrl = useDeleteProformaDialogController(); const issueDialogCtrl = useIssueProformaDialogController(); @@ -116,9 +122,42 @@ export const useListProformasPageController = (scope: ProformaListScope) => { } }, [t, unarchiveMutation, unarchiveTarget]); + const retryList = useCallback(async () => { + await listCtrl.refetch(); + }, [listCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const pageError = useMemo(() => { + if (!(listCtrl.isError && !listCtrl.hasUsableData)) { + return null; + } + + return buildProformaListPageErrorViewModel(listCtrl.error, (key) => t(key)); + }, [listCtrl.error, listCtrl.hasUsableData, listCtrl.isError, t]); + + const refreshError = useMemo(() => { + if (!resolveProformaListFeedbackState(listCtrl).hasRefreshError) { + return null; + } + + return buildProformaListRefreshErrorViewModel(listCtrl.error, (key) => t(key)); + }, [listCtrl.error, listCtrl.hasUsableData, listCtrl.isError, t]); + + const feedbackState = resolveProformaListFeedbackState(listCtrl); + return { listCtrl, panelCtrl, + pageError, + refreshError, + hasBlockingError: feedbackState.hasBlockingError && pageError !== null, + hasRefreshError: feedbackState.hasRefreshError && refreshError !== null, + retryList, + goHome, + isRetrying: listCtrl.isFetching, archiveDialogCtrl: { open: archiveTarget !== null, diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index ddf67515..6ab6b89b 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -229,9 +229,11 @@ export const useListProformasController = (scope: ProformaListScope) => { ); return { + queryData: query.data, data: query.data ?? EMPTY_PROFORMAS_LIST, isLoading: query.isLoading, isFetching: query.isFetching, + hasUsableData: query.data !== undefined, isError: query.isError, error: query.error, diff --git a/modules/customer-invoices/src/web/proformas/list/entities/index.ts b/modules/customer-invoices/src/web/proformas/list/entities/index.ts new file mode 100644 index 00000000..17f167f2 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/entities/index.ts @@ -0,0 +1 @@ +export * from "./proforma-list-page-error-view-model.entity"; diff --git a/modules/customer-invoices/src/web/proformas/list/entities/proforma-list-page-error-view-model.entity.ts b/modules/customer-invoices/src/web/proformas/list/entities/proforma-list-page-error-view-model.entity.ts new file mode 100644 index 00000000..f168d03b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/entities/proforma-list-page-error-view-model.entity.ts @@ -0,0 +1,14 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface ProformaListPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} + +export interface ProformaListRefreshErrorViewModel { + title: string; + message: string; +} diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index b5361478..cdaf135f 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -1,6 +1,6 @@ -import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components"; +import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { useDataSource, useReturnToNavigation } from "@erp/core/hooks"; -import { AppContent, BackHistoryButton, type DataTableMeta } from "@repo/rdx-ui/components"; +import { type DataTableMeta } from "@repo/rdx-ui/components"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { Button, @@ -234,6 +234,11 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { unarchiveDialogCtrl, isPDFDownloading: isPdfDownloading, pdfDownloadingId, + pageError, + refreshError, + retryList, + goHome, + isRetrying, } = useListProformasPageController(scope); const handleEditClick = (proformaId: string) => { @@ -428,15 +433,32 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
); - if (listCtrl.isError) { + if (pageError) { return ( - - - - +
+
+ +
+
); } @@ -467,6 +489,8 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { title={t(scopeConfig.titleKey)} /> + {refreshError ? : null} + {/* Stats Cards */}
diff --git a/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.test.ts b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.test.ts new file mode 100644 index 00000000..b08cf048 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildProformaListPageErrorViewModel, + buildProformaListRefreshErrorViewModel, +} from "./build-proforma-list-page-error-view-model"; + +const translate = (key: string) => + ({ + "pages.proformas.list.errors.page.forbidden.title": "No tienes acceso al listado de proformas", + "pages.proformas.list.errors.page.forbidden.description": + "No dispones de permisos para consultar este listado.", + "pages.proformas.list.errors.page.network.title": "No se pudo conectar con el servidor", + "pages.proformas.list.errors.page.network.description": + "Comprueba tu conexión e inténtalo de nuevo.", + "pages.proformas.list.errors.page.timeout.title": "La carga está tardando demasiado", + "pages.proformas.list.errors.page.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "pages.proformas.list.errors.page.server.title": "No se pudo cargar el listado de proformas", + "pages.proformas.list.errors.page.server.description": + "Se produjo un error interno al recuperar las proformas.", + "pages.proformas.list.errors.page.unavailable.title": "Servicio temporalmente no disponible", + "pages.proformas.list.errors.page.unavailable.description": + "El servicio de proformas no está disponible en este momento.", + "pages.proformas.list.errors.page.unknown.title": "No se pudo cargar el listado de proformas", + "pages.proformas.list.errors.page.unknown.description": + "Ha ocurrido un error inesperado al cargar el listado.", + "pages.proformas.list.errors.refresh.title": "No se pudo actualizar el listado", + "pages.proformas.list.errors.refresh.message": + "Se muestran los últimos datos disponibles mientras vuelves a intentarlo.", + })[key] ?? key; + +test("mapea forbidden sin retry y conserva la referencia segura", () => { + const viewModel = buildProformaListPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical", correlationId: "corr-1" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-1"); +}); + +test("mapea network, timeout, server y unavailable con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable"] as const) { + const viewModel = buildProformaListPageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("mapea notFound a unknown en listados", () => { + const viewModel = buildProformaListPageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical not found" }), + translate + ); + + assert.equal(viewModel?.variant, "unknown"); + assert.equal(viewModel?.description, "Ha ocurrido un error inesperado al cargar el listado."); +}); + +test("ignora cancelled para page error y refresh error", () => { + const pageError = buildProformaListPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + const refreshError = buildProformaListRefreshErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(pageError, null); + assert.equal(refreshError, null); +}); + +test("refresh error no expone el mensaje técnico", () => { + const refreshError = buildProformaListRefreshErrorViewModel(new Error("boom"), translate); + + assert.equal( + refreshError?.message, + "Se muestran los últimos datos disponibles mientras vuelves a intentarlo." + ); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.ts b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.ts new file mode 100644 index 00000000..8b62bc66 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-list-page-error-view-model.ts @@ -0,0 +1,68 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { + ProformaListPageErrorViewModel, + ProformaListRefreshErrorViewModel, +} from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildVariant = (error: unknown): ProformaListPageErrorViewModel["variant"] | null => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return null; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + case "notFound": + return "unknown"; + default: + return "unknown"; + } +}; + +export const buildProformaListPageErrorViewModel = ( + error: unknown, + translate: Translate +): ProformaListPageErrorViewModel | null => { + const variant = buildVariant(error); + + if (!variant) { + return null; + } + + return { + variant, + title: translate(`pages.proformas.list.errors.page.${variant}.title`), + description: translate(`pages.proformas.list.errors.page.${variant}.description`), + canRetry: variant !== "forbidden", + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildProformaListRefreshErrorViewModel = ( + error: unknown, + translate: Translate +): ProformaListRefreshErrorViewModel | null => { + if (isApiClientError(error) && error.kind === "cancelled") { + return null; + } + + return { + title: translate("pages.proformas.list.errors.refresh.title"), + message: translate("pages.proformas.list.errors.refresh.message"), + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.test.ts b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.test.ts new file mode 100644 index 00000000..98df2eb2 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveProformaListFeedbackState } from "./resolve-proforma-list-feedback-state"; + +test("marca error bloqueante cuando falla la carga inicial sin datos", () => { + const state = resolveProformaListFeedbackState({ + isError: true, + hasUsableData: false, + }); + + assert.equal(state.hasBlockingError, true); + assert.equal(state.hasRefreshError, false); +}); + +test("mantiene la tabla visible cuando hay datos utilizables y falla un refetch", () => { + const state = resolveProformaListFeedbackState({ + isError: true, + hasUsableData: true, + }); + + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasRefreshError, true); +}); + +test("no mezcla empty o carga correcta con estados de error", () => { + const state = resolveProformaListFeedbackState({ + isError: false, + hasUsableData: true, + }); + + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasRefreshError, false); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.ts b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.ts new file mode 100644 index 00000000..534fda85 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-list-feedback-state.ts @@ -0,0 +1,19 @@ +interface ResolveProformaListFeedbackStateParams { + isError: boolean; + hasUsableData: boolean; +} + +export interface ProformaListFeedbackState { + hasBlockingError: boolean; + hasRefreshError: boolean; +} + +export const resolveProformaListFeedbackState = ({ + isError, + hasUsableData, +}: ResolveProformaListFeedbackStateParams): ProformaListFeedbackState => { + return { + hasBlockingError: isError && !hasUsableData, + hasRefreshError: isError && hasUsableData, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index 231ea908..df9f25c3 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -67,8 +67,10 @@ export const useUpdateProformaController = ( const { data: proformaData, isLoading, + isFetching, isError: isLoadError, error: loadError, + refetch, } = useProformaGetQuery({ id: proformaId, enabled: Boolean(proformaId) }); // 2) Estado de creación (mutación) @@ -298,8 +300,10 @@ export const useUpdateProformaController = ( // carga de datos proformaData, isLoading, + isFetching, isLoadError, loadError, + refetch, // mutation isUpdating, diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts index 2543653a..ddb66893 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts @@ -1,16 +1,31 @@ +import { useCallback, useMemo } from "react"; import { useUrlParamId } from "@erp/core/hooks"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useCustomerSelectionFlow } from "@erp/customers/common"; -import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "../../../i18n"; +import { + buildProformaUpdateMissingEntityPageErrorViewModel, + buildProformaUpdatePageErrorViewModel, + resolveProformaUpdatePageFeedbackState, +} from "../utils"; import { useUpdateProformaController } from "./use-update-proforma-controller"; export const useUpdateProformaPageController = () => { + const { t } = useTranslation(); const proformaId = useUrlParamId(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const updateCtrl = useUpdateProformaController(proformaId); const returnTo = searchParams.get("returnTo") ?? "/proformas"; + const feedbackState = resolveProformaUpdatePageFeedbackState({ + isLoading: updateCtrl.isLoading, + isLoadError: updateCtrl.isLoadError, + hasProformaData: Boolean(updateCtrl.proformaData), + error: updateCtrl.loadError, + }); const selectCustomerCtrl = useCustomerSelectionFlow({ defaultLanguageCode: updateCtrl.form.watch("languageCode"), @@ -20,9 +35,39 @@ export const useUpdateProformaPageController = () => { }, }); + const retryUpdate = useCallback(async () => { + await updateCtrl.refetch(); + }, [updateCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const goBackToList = useCallback(() => { + navigate(returnTo); + }, [navigate, returnTo]); + + const pageError = useMemo(() => { + if (feedbackState.hasBlockingError) { + return buildProformaUpdatePageErrorViewModel(updateCtrl.loadError, (key) => t(key)); + } + + if (feedbackState.hasDataInconsistency) { + return buildProformaUpdateMissingEntityPageErrorViewModel((key) => t(key)); + } + + return null; + }, [feedbackState.hasBlockingError, feedbackState.hasDataInconsistency, t, updateCtrl.loadError]); + return { updateCtrl, selectCustomerCtrl, returnTo, + pageError, + retryUpdate, + goHome, + goBackToList, + isRetrying: updateCtrl.isFetching && !updateCtrl.isLoading, + isInitialLoading: feedbackState.isInitialLoading, }; }; diff --git a/modules/customer-invoices/src/web/proformas/update/entities/index.ts b/modules/customer-invoices/src/web/proformas/update/entities/index.ts index 98aea30f..364990e2 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/index.ts @@ -4,4 +4,5 @@ export * from "./proforma-item-update-form.schema"; export * from "./proforma-item-update-patch.entity"; export * from "./proforma-update-form.entity"; export * from "./proforma-update-form.schema"; +export * from "./proforma-update-page-error-view-model.entity"; export * from "./proforma-update-patch.entity"; diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-page-error-view-model.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-page-error-view-model.entity.ts new file mode 100644 index 00000000..50d2e341 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface ProformaUpdatePageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index 97a7384a..5fdceb67 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -1,7 +1,7 @@ -import { ErrorAlert, NotFoundCard } from "@erp/core/components"; +import { ErrorAlert, PageErrorState } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; import { SelectCustomerDialog } from "@erp/customers"; -import { AppContent, BackHistoryButton, PercentageField } from "@repo/rdx-ui/components"; +import { AppContent, PercentageField } from "@repo/rdx-ui/components"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "../../../../i18n"; @@ -13,7 +13,8 @@ import { ProformaUpdateEditorForm } from "../editors"; export const ProformaUpdatePage = () => { const { t } = useTranslation(); - const { updateCtrl, selectCustomerCtrl, returnTo } = useUpdateProformaPageController(); + const ctrl = useUpdateProformaPageController(); + const { updateCtrl, selectCustomerCtrl, returnTo } = ctrl; const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo, @@ -21,42 +22,52 @@ export const ProformaUpdatePage = () => { const handleCancel = () => navigateBack(); - if (updateCtrl.isLoading) { + if (ctrl.isInitialLoading) { return ; } - if (updateCtrl.isLoadError) { + if (ctrl.pageError) { return ( - - - -
- navigateBack()} /> + +
+
); } - if (!updateCtrl.proformaData) - return ( - - - - ); - return (
diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.test.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.test.ts new file mode 100644 index 00000000..5943dcb5 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildProformaUpdateMissingEntityPageErrorViewModel, + buildProformaUpdatePageErrorViewModel, +} from "./build-proforma-update-page-error-view-model"; + +const translate = (key: string) => + ({ + "pages.proformas.update.page_error.notFound.title": "Proforma no encontrada", + "pages.proformas.update.page_error.notFound.description": + "La proforma ya no existe o no está disponible.", + "pages.proformas.update.page_error.forbidden.title": + "No tienes acceso a esta proforma", + "pages.proformas.update.page_error.forbidden.description": + "No dispones de permisos para editar este documento.", + "pages.proformas.update.page_error.network.title": + "No se pudo conectar con el servidor", + "pages.proformas.update.page_error.network.description": + "Comprueba tu conexión e inténtalo de nuevo.", + "pages.proformas.update.page_error.timeout.title": "La carga está tardando demasiado", + "pages.proformas.update.page_error.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "pages.proformas.update.page_error.server.title": "No se pudo cargar la proforma", + "pages.proformas.update.page_error.server.description": + "Se produjo un error interno al recuperar la proforma.", + "pages.proformas.update.page_error.unavailable.title": + "Servicio temporalmente no disponible", + "pages.proformas.update.page_error.unavailable.description": + "El servicio de proformas no está disponible en este momento.", + "pages.proformas.update.page_error.unknown.title": "No se pudo cargar la proforma", + "pages.proformas.update.page_error.unknown.description": + "Ha ocurrido un error inesperado al cargar la proforma.", + "pages.proformas.update.page_error.missing_entity.title": "No se pudo cargar la proforma", + "pages.proformas.update.page_error.missing_entity.description": + "La respuesta recibida no contiene una proforma válida.", + })[key] ?? key; + +test("mapea notFound real a notFound sin retry", () => { + const viewModel = buildProformaUpdatePageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }), + translate + ); + + assert.equal(viewModel?.variant, "notFound"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-404"); +}); + +test("mapea forbidden sin retry", () => { + const viewModel = buildProformaUpdatePageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); +}); + +test("mapea network, timeout, server, unavailable y unknown con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) { + const viewModel = buildProformaUpdatePageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("ignora cancelled", () => { + const viewModel = buildProformaUpdatePageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(viewModel, null); +}); + +test("trata una respuesta exitosa sin entidad como unknown por inconsistencia", () => { + const viewModel = buildProformaUpdateMissingEntityPageErrorViewModel(translate); + + assert.equal(viewModel.variant, "unknown"); + assert.equal(viewModel.canRetry, true); + assert.equal(viewModel.description, "La respuesta recibida no contiene una proforma válida."); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.ts new file mode 100644 index 00000000..c3682cba --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-proforma-update-page-error-view-model.ts @@ -0,0 +1,64 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { ProformaUpdatePageErrorViewModel } from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildErrorVariant = ( + error: unknown +): ProformaUpdatePageErrorViewModel["variant"] | "cancelled" => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return "cancelled"; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } +}; + +export const buildProformaUpdatePageErrorViewModel = ( + error: unknown, + translate: Translate +): ProformaUpdatePageErrorViewModel | null => { + const variant = buildErrorVariant(error); + + if (variant === "cancelled") { + return null; + } + + return { + variant, + title: translate(`pages.proformas.update.page_error.${variant}.title`), + description: translate(`pages.proformas.update.page_error.${variant}.description`), + canRetry: !["forbidden", "notFound"].includes(variant), + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildProformaUpdateMissingEntityPageErrorViewModel = ( + translate: Translate +): ProformaUpdatePageErrorViewModel => { + return { + variant: "unknown", + title: translate("pages.proformas.update.page_error.missing_entity.title"), + description: translate("pages.proformas.update.page_error.missing_entity.description"), + canRetry: true, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/index.ts b/modules/customer-invoices/src/web/proformas/update/utils/index.ts index 7d14a23a..acb9c7dd 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/index.ts @@ -1,5 +1,7 @@ export * from "./build-proforma-item-update-default"; export * from "./build-proforma-update-default"; +export * from "./build-proforma-update-page-error-view-model"; export * from "./build-proforma-update-patch"; export * from "./build-update-proforma-by-id-params"; export * from "./calculate-proforma-totals"; +export * from "./resolve-proforma-update-page-feedback-state"; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.test.ts b/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.test.ts new file mode 100644 index 00000000..90271271 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { resolveProformaUpdatePageFeedbackState } from "./resolve-proforma-update-page-feedback-state"; + +test("mantiene loading si la carga inicial sigue pendiente", () => { + assert.deepEqual( + resolveProformaUpdatePageFeedbackState({ + isLoading: true, + isLoadError: false, + hasProformaData: false, + error: null, + }), + { + isInitialLoading: true, + hasBlockingError: false, + hasDataInconsistency: false, + } + ); +}); + +test("ignora cancelled sin mostrar pantalla bloqueante", () => { + assert.deepEqual( + resolveProformaUpdatePageFeedbackState({ + isLoading: false, + isLoadError: true, + hasProformaData: false, + error: new ApiClientError({ kind: "cancelled", message: "technical" }), + }), + { + isInitialLoading: true, + hasBlockingError: false, + hasDataInconsistency: false, + } + ); +}); + +test("marca error bloqueante cuando falla sin datos utilizables", () => { + assert.deepEqual( + resolveProformaUpdatePageFeedbackState({ + isLoading: false, + isLoadError: true, + hasProformaData: false, + error: new ApiClientError({ kind: "server", message: "technical" }), + }), + { + isInitialLoading: false, + hasBlockingError: true, + hasDataInconsistency: false, + } + ); +}); + +test("marca inconsistencia cuando la respuesta es correcta pero no hay entidad", () => { + assert.deepEqual( + resolveProformaUpdatePageFeedbackState({ + isLoading: false, + isLoadError: false, + hasProformaData: false, + error: null, + }), + { + isInitialLoading: false, + hasBlockingError: false, + hasDataInconsistency: true, + } + ); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.ts b/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.ts new file mode 100644 index 00000000..ddc6b2e3 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/resolve-proforma-update-page-feedback-state.ts @@ -0,0 +1,29 @@ +import { isApiClientError } from "@erp/core/client"; + +interface ResolveProformaUpdatePageFeedbackStateParams { + isLoading: boolean; + isLoadError: boolean; + hasProformaData: boolean; + error: unknown; +} + +export interface ProformaUpdatePageFeedbackState { + isInitialLoading: boolean; + hasBlockingError: boolean; + hasDataInconsistency: boolean; +} + +export const resolveProformaUpdatePageFeedbackState = ({ + isLoading, + isLoadError, + hasProformaData, + error, +}: ResolveProformaUpdatePageFeedbackStateParams): ProformaUpdatePageFeedbackState => { + const isCancelled = isApiClientError(error) && error.kind === "cancelled"; + + return { + isInitialLoading: isLoading || (isLoadError && isCancelled && !hasProformaData), + hasBlockingError: isLoadError && !isCancelled && !hasProformaData, + hasDataInconsistency: !isLoading && !isLoadError && !hasProformaData, + }; +}; diff --git a/modules/customers/src/common/locales/en.json b/modules/customers/src/common/locales/en.json index cd87b487..1eb21fb2 100644 --- a/modules/customers/src/common/locales/en.json +++ b/modules/customers/src/common/locales/en.json @@ -26,6 +26,9 @@ "delete": "Delete customer", "edit": "Edit customer", "more": "More", + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home", "view": "View" }, "columns": { @@ -37,7 +40,37 @@ "description": "Manage your customers and their contact information.", "errors": { "load_message": "Please try again later.", - "load_title": "The customer list could not be loaded" + "load_title": "The customer list could not be loaded", + "refresh": { + "title": "The customer list could not be refreshed", + "message": "The latest available data is shown while you try again." + }, + "page": { + "forbidden": { + "title": "You do not have access to the customer list", + "description": "You do not have permission to view this list." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "The customer list could not be loaded", + "description": "An internal error occurred while retrieving the customers." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The customer service is not available right now." + }, + "unknown": { + "title": "The customer list could not be loaded", + "description": "An unexpected error occurred while loading the list." + } + } }, "title": "Customers" }, @@ -50,6 +83,46 @@ "load_message": "Please try again later.", "load_title": "The customer could not be loaded" }, + "page_error": { + "actions": { + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home", + "back_to_list": "Back to list" + }, + "notFound": { + "title": "Customer not found", + "description": "The customer no longer exists or is not available." + }, + "forbidden": { + "title": "You do not have access to this customer", + "description": "You do not have permission to view this customer." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "The customer could not be loaded", + "description": "An internal error occurred while retrieving the customer." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The customer service is not available right now." + }, + "unknown": { + "title": "The customer could not be loaded", + "description": "An unexpected error occurred while loading the customer." + }, + "missing_entity": { + "title": "The customer could not be loaded", + "description": "The response does not contain a valid customer." + } + }, "fields": { "city": "City", "country": "Country", @@ -118,6 +191,46 @@ "title": "The changes could not be saved", "unexpected": "An unexpected error occurred." }, + "page_error": { + "actions": { + "retry": "Retry", + "retrying": "Retrying…", + "go_home": "Back to home", + "back_to_list": "Back to list" + }, + "notFound": { + "title": "Customer not found", + "description": "The customer no longer exists or is not available." + }, + "forbidden": { + "title": "You do not have access to this customer", + "description": "You do not have permission to edit this customer." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "The customer could not be loaded", + "description": "An internal error occurred while retrieving the customer." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The customer service is not available right now." + }, + "unknown": { + "title": "The customer could not be loaded", + "description": "An unexpected error occurred while loading the customer." + }, + "missing_entity": { + "title": "The customer could not be loaded", + "description": "The response does not contain a valid customer." + } + }, "feedback": { "no_changes": { "message": "You have not made any changes.", diff --git a/modules/customers/src/common/locales/es.json b/modules/customers/src/common/locales/es.json index cc7521fc..04e44c16 100644 --- a/modules/customers/src/common/locales/es.json +++ b/modules/customers/src/common/locales/es.json @@ -26,6 +26,9 @@ "delete": "Eliminar cliente", "edit": "Editar cliente", "more": "Más", + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio", "view": "Ver" }, "columns": { @@ -37,7 +40,37 @@ "description": "Gestiona la información de tus clientes y sus datos de contacto.", "errors": { "load_message": "Inténtalo de nuevo más tarde.", - "load_title": "No se pudo cargar la lista de clientes" + "load_title": "No se pudo cargar la lista de clientes", + "refresh": { + "title": "No se pudo actualizar la lista de clientes", + "message": "Se muestran los últimos datos disponibles mientras vuelves a intentarlo." + }, + "page": { + "forbidden": { + "title": "No tienes acceso al listado de clientes", + "description": "No dispones de permisos para consultar este listado." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar la lista de clientes", + "description": "Se produjo un error interno al recuperar los clientes." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de clientes no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar la lista de clientes", + "description": "Ha ocurrido un error inesperado al cargar el listado." + } + } }, "title": "Clientes" }, @@ -50,6 +83,46 @@ "load_message": "Inténtalo de nuevo más tarde.", "load_title": "No se pudo cargar el cliente" }, + "page_error": { + "actions": { + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio", + "back_to_list": "Volver al listado" + }, + "notFound": { + "title": "Cliente no encontrado", + "description": "El cliente ya no existe o no está disponible." + }, + "forbidden": { + "title": "No tienes acceso a este cliente", + "description": "No dispones de permisos para consultar este cliente." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar el cliente", + "description": "Se produjo un error interno al recuperar el cliente." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de clientes no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar el cliente", + "description": "Ha ocurrido un error inesperado al cargar el cliente." + }, + "missing_entity": { + "title": "No se pudo cargar el cliente", + "description": "La respuesta recibida no contiene un cliente válido." + } + }, "fields": { "city": "Ciudad", "country": "País", @@ -118,6 +191,46 @@ "title": "No se pudo guardar los cambios", "unexpected": "Ha ocurrido un error inesperado." }, + "page_error": { + "actions": { + "retry": "Reintentar", + "retrying": "Reintentando…", + "go_home": "Volver al inicio", + "back_to_list": "Volver al listado" + }, + "notFound": { + "title": "Cliente no encontrado", + "description": "El cliente ya no existe o no está disponible." + }, + "forbidden": { + "title": "No tienes acceso a este cliente", + "description": "No dispones de permisos para editar este cliente." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar el cliente", + "description": "Se produjo un error interno al recuperar el cliente." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de clientes no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar el cliente", + "description": "Ha ocurrido un error inesperado al cargar el cliente." + }, + "missing_entity": { + "title": "No se pudo cargar el cliente", + "description": "La respuesta recibida no contiene un cliente válido." + } + }, "feedback": { "no_changes": { "message": "No has realizado ningún cambio.", diff --git a/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts b/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts index 2f302e77..7cf83ee8 100644 --- a/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts +++ b/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts @@ -1,10 +1,20 @@ import type { RightPanelMode } from "@repo/rdx-ui/hooks"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { useTranslation } from "../../i18n"; +import { + buildCustomerListPageErrorViewModel, + buildCustomerListRefreshErrorViewModel, +} from "../utils"; +import { resolveCustomerListFeedbackState } from "../utils/resolve-customer-list-feedback-state"; import { useCustomerSummaryPanelController } from "./use-customer-summary-panel.controller"; import { useListCustomersController } from "./use-list-customers.controller"; export const useListCustomersPageController = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); const listCtrl = useListCustomersController(); const [searchParams] = useSearchParams(); @@ -18,8 +28,41 @@ export const useListCustomersPageController = () => { initialOpen: customerId !== "", }); + const retryList = useCallback(async () => { + await listCtrl.refetch(); + }, [listCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const pageError = useMemo(() => { + if (!(listCtrl.isError && !listCtrl.hasUsableData)) { + return null; + } + + return buildCustomerListPageErrorViewModel(listCtrl.error, (key) => t(key)); + }, [listCtrl.error, listCtrl.hasUsableData, listCtrl.isError, t]); + + const refreshError = useMemo(() => { + if (!resolveCustomerListFeedbackState(listCtrl).hasRefreshError) { + return null; + } + + return buildCustomerListRefreshErrorViewModel(listCtrl.error, (key) => t(key)); + }, [listCtrl.error, listCtrl.hasUsableData, listCtrl.isError, t]); + + const feedbackState = resolveCustomerListFeedbackState(listCtrl); + return { listCtrl, panelCtrl, + pageError, + refreshError, + hasBlockingError: feedbackState.hasBlockingError && pageError !== null, + hasRefreshError: feedbackState.hasRefreshError && refreshError !== null, + retryList, + goHome, + isRetrying: listCtrl.isFetching, }; }; diff --git a/modules/customers/src/web/list/controllers/use-list-customers.controller.ts b/modules/customers/src/web/list/controllers/use-list-customers.controller.ts index ef242d8f..3c162d79 100644 --- a/modules/customers/src/web/list/controllers/use-list-customers.controller.ts +++ b/modules/customers/src/web/list/controllers/use-list-customers.controller.ts @@ -219,9 +219,11 @@ export const useListCustomersController = () => { ); return { + queryData: query.data, data: query.data ?? EMPTY_CUSTOMERS_LIST, isLoading: query.isLoading, isFetching: query.isFetching, + hasUsableData: query.data !== undefined, isError: query.isError, error: query.error, diff --git a/modules/customers/src/web/list/entities/customer-list-page-error-view-model.entity.ts b/modules/customers/src/web/list/entities/customer-list-page-error-view-model.entity.ts new file mode 100644 index 00000000..7790efa5 --- /dev/null +++ b/modules/customers/src/web/list/entities/customer-list-page-error-view-model.entity.ts @@ -0,0 +1,14 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface CustomerListPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} + +export interface CustomerListRefreshErrorViewModel { + title: string; + message: string; +} diff --git a/modules/customers/src/web/list/entities/index.ts b/modules/customers/src/web/list/entities/index.ts new file mode 100644 index 00000000..670b65b8 --- /dev/null +++ b/modules/customers/src/web/list/entities/index.ts @@ -0,0 +1 @@ +export * from "./customer-list-page-error-view-model.entity"; diff --git a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx index a5fb2886..68fd766c 100644 --- a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx +++ b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx @@ -1,6 +1,5 @@ -import { PageHeader, SimpleSearchInput } from "@erp/core/components"; +import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { useReturnToNavigation } from "@erp/core/hooks"; -import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { Button, ResizableHandle, @@ -12,7 +11,6 @@ import { useState } from "react"; import { createSearchParams, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../i18n"; -import { ErrorAlert } from "../../../shared/ui"; import { useListCustomersPageController } from "../../controllers"; import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks"; @@ -25,7 +23,8 @@ export const ListCustomersPage = () => { }); const [isSearchPending, setIsSearchPending] = useState(false); - const { listCtrl, panelCtrl } = useListCustomersPageController(); + const { listCtrl, panelCtrl, pageError, refreshError, retryList, goHome, isRetrying } = + useListCustomersPageController(); const handleEditClick = (customerId: string) => { navigate({ @@ -87,15 +86,30 @@ export const ListCustomersPage = () => {
); - if (listCtrl.isError) { + if (pageError) { return ( - - - - +
+
+ +
+
); } @@ -117,6 +131,8 @@ export const ListCustomersPage = () => { title={t("list.title")} /> + {refreshError ? : null} + {/* Table */} {isPanelOpen ? ( + ({ + "list.errors.page.forbidden.title": "No tienes acceso al listado de clientes", + "list.errors.page.forbidden.description": + "No dispones de permisos para consultar este listado.", + "list.errors.page.network.title": "No se pudo conectar con el servidor", + "list.errors.page.network.description": "Comprueba tu conexión e inténtalo de nuevo.", + "list.errors.page.timeout.title": "La carga está tardando demasiado", + "list.errors.page.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "list.errors.page.server.title": "No se pudo cargar la lista de clientes", + "list.errors.page.server.description": + "Se produjo un error interno al recuperar los clientes.", + "list.errors.page.unavailable.title": "Servicio temporalmente no disponible", + "list.errors.page.unavailable.description": + "El servicio de clientes no está disponible en este momento.", + "list.errors.page.unknown.title": "No se pudo cargar la lista de clientes", + "list.errors.page.unknown.description": + "Ha ocurrido un error inesperado al cargar el listado.", + "list.errors.refresh.title": "No se pudo actualizar la lista de clientes", + "list.errors.refresh.message": + "Se muestran los últimos datos disponibles mientras vuelves a intentarlo.", + })[key] ?? key; + +test("mapea forbidden sin retry y conserva la referencia segura", () => { + const viewModel = buildCustomerListPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical", correlationId: "corr-1" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-1"); +}); + +test("mapea network, timeout, server y unavailable con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable"] as const) { + const viewModel = buildCustomerListPageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("mapea notFound a unknown en listados", () => { + const viewModel = buildCustomerListPageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical not found" }), + translate + ); + + assert.equal(viewModel?.variant, "unknown"); + assert.equal(viewModel?.description, "Ha ocurrido un error inesperado al cargar el listado."); +}); + +test("ignora cancelled para page error y refresh error", () => { + const pageError = buildCustomerListPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + const refreshError = buildCustomerListRefreshErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(pageError, null); + assert.equal(refreshError, null); +}); + +test("refresh error no expone el mensaje técnico", () => { + const refreshError = buildCustomerListRefreshErrorViewModel(new Error("boom"), translate); + + assert.equal( + refreshError?.message, + "Se muestran los últimos datos disponibles mientras vuelves a intentarlo." + ); +}); diff --git a/modules/customers/src/web/list/utils/build-customer-list-page-error-view-model.ts b/modules/customers/src/web/list/utils/build-customer-list-page-error-view-model.ts new file mode 100644 index 00000000..c234ba12 --- /dev/null +++ b/modules/customers/src/web/list/utils/build-customer-list-page-error-view-model.ts @@ -0,0 +1,68 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { + CustomerListPageErrorViewModel, + CustomerListRefreshErrorViewModel, +} from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildVariant = (error: unknown): CustomerListPageErrorViewModel["variant"] | null => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return null; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + case "notFound": + return "unknown"; + default: + return "unknown"; + } +}; + +export const buildCustomerListPageErrorViewModel = ( + error: unknown, + translate: Translate +): CustomerListPageErrorViewModel | null => { + const variant = buildVariant(error); + + if (!variant) { + return null; + } + + return { + variant, + title: translate(`list.errors.page.${variant}.title`), + description: translate(`list.errors.page.${variant}.description`), + canRetry: variant !== "forbidden", + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildCustomerListRefreshErrorViewModel = ( + error: unknown, + translate: Translate +): CustomerListRefreshErrorViewModel | null => { + if (isApiClientError(error) && error.kind === "cancelled") { + return null; + } + + return { + title: translate("list.errors.refresh.title"), + message: translate("list.errors.refresh.message"), + }; +}; diff --git a/modules/customers/src/web/list/utils/error-alert-deduplication.test.ts b/modules/customers/src/web/list/utils/error-alert-deduplication.test.ts new file mode 100644 index 00000000..3d438078 --- /dev/null +++ b/modules/customers/src/web/list/utils/error-alert-deduplication.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const sharedErrorAlertPath = resolve( + currentDir, + "../../shared/ui/components/error-alert.tsx" +); +const sharedComponentsIndexPath = resolve( + currentDir, + "../../shared/ui/components/index.ts" +); +const listPagePath = resolve(currentDir, "../ui/pages/list-customers-page.tsx"); + +test("el módulo customers ya no mantiene un ErrorAlert local duplicado", () => { + assert.equal(existsSync(sharedErrorAlertPath), false); + + const sharedIndex = readFileSync(sharedComponentsIndexPath, "utf8"); + const listPage = readFileSync(listPagePath, "utf8"); + + assert.doesNotMatch(sharedIndex, /error-alert/); + assert.match(listPage, /@erp\/core\/components/); +}); diff --git a/modules/customers/src/web/list/utils/index.ts b/modules/customers/src/web/list/utils/index.ts new file mode 100644 index 00000000..fb31153d --- /dev/null +++ b/modules/customers/src/web/list/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./build-customer-list-page-error-view-model"; +export * from "./resolve-customer-list-feedback-state"; diff --git a/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.test.ts b/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.test.ts new file mode 100644 index 00000000..3e1faf28 --- /dev/null +++ b/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCustomerListFeedbackState } from "./resolve-customer-list-feedback-state"; + +test("marca error bloqueante cuando falla la carga inicial sin datos", () => { + const state = resolveCustomerListFeedbackState({ + isError: true, + hasUsableData: false, + }); + + assert.equal(state.hasBlockingError, true); + assert.equal(state.hasRefreshError, false); +}); + +test("mantiene la tabla visible cuando hay datos utilizables y falla un refetch", () => { + const state = resolveCustomerListFeedbackState({ + isError: true, + hasUsableData: true, + }); + + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasRefreshError, true); +}); + +test("no mezcla empty o carga correcta con estados de error", () => { + const state = resolveCustomerListFeedbackState({ + isError: false, + hasUsableData: true, + }); + + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasRefreshError, false); +}); diff --git a/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.ts b/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.ts new file mode 100644 index 00000000..4cebe9ed --- /dev/null +++ b/modules/customers/src/web/list/utils/resolve-customer-list-feedback-state.ts @@ -0,0 +1,19 @@ +interface ResolveCustomerListFeedbackStateParams { + isError: boolean; + hasUsableData: boolean; +} + +export interface CustomerListFeedbackState { + hasBlockingError: boolean; + hasRefreshError: boolean; +} + +export const resolveCustomerListFeedbackState = ({ + isError, + hasUsableData, +}: ResolveCustomerListFeedbackStateParams): CustomerListFeedbackState => { + return { + hasBlockingError: isError && !hasUsableData, + hasRefreshError: isError && hasUsableData, + }; +}; diff --git a/modules/customers/src/web/shared/ui/components/error-alert.tsx b/modules/customers/src/web/shared/ui/components/error-alert.tsx deleted file mode 100644 index 01098060..00000000 --- a/modules/customers/src/web/shared/ui/components/error-alert.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// components/ErrorAlert.tsx -interface ErrorAlertProps { - title: string; - message: string; -} - -export const ErrorAlert = ({ title, message }: ErrorAlertProps) => ( -
-

{title}

-

{message}

-
-); diff --git a/modules/customers/src/web/shared/ui/components/index.ts b/modules/customers/src/web/shared/ui/components/index.ts index 31f3586f..612268d5 100644 --- a/modules/customers/src/web/shared/ui/components/index.ts +++ b/modules/customers/src/web/shared/ui/components/index.ts @@ -1,2 +1 @@ export * from "./customer-status-badge"; -export * from "./error-alert"; \ No newline at end of file diff --git a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts index 7c549186..c0e61eef 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts @@ -1,6 +1,13 @@ +import { useCallback, useMemo } from "react"; import { useUrlParamId } from "@erp/core/hooks"; import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "../../i18n"; +import { + buildCustomerUpdateMissingEntityPageErrorViewModel, + buildCustomerUpdatePageErrorViewModel, + resolveCustomerUpdatePageFeedbackState, +} from "../utils"; import { useCustomerUpdateController } from "./use-customer-update.controller"; /** @@ -10,6 +17,7 @@ import { useCustomerUpdateController } from "./use-customer-update.controller"; */ export const useCustomerUpdatePageController = () => { + const { t } = useTranslation(); const customerId = useUrlParamId(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -26,8 +34,45 @@ export const useCustomerUpdatePageController = () => { }, }); + const feedbackState = resolveCustomerUpdatePageFeedbackState({ + isLoading: updateCtrl.isLoading, + isLoadError: updateCtrl.isLoadError, + hasCustomerData: Boolean(updateCtrl.customerData), + error: updateCtrl.loadError, + }); + + const retryUpdate = useCallback(async () => { + await updateCtrl.refetch(); + }, [updateCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const goBackToList = useCallback(() => { + navigate(returnTo); + }, [navigate, returnTo]); + + const pageError = useMemo(() => { + if (feedbackState.hasBlockingError) { + return buildCustomerUpdatePageErrorViewModel(updateCtrl.loadError, (key) => t(key)); + } + + if (feedbackState.hasDataInconsistency) { + return buildCustomerUpdateMissingEntityPageErrorViewModel((key) => t(key)); + } + + return null; + }, [feedbackState.hasBlockingError, feedbackState.hasDataInconsistency, t, updateCtrl.loadError]); + return { updateCtrl, returnTo, + pageError, + retryUpdate, + goHome, + goBackToList, + isRetrying: updateCtrl.isFetching && !updateCtrl.isLoading, + isInitialLoading: feedbackState.isInitialLoading, }; }; diff --git a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts index 8d47f39f..d93fb6cd 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts @@ -59,8 +59,10 @@ export const useCustomerUpdateController = ( const { data: customerData, isLoading, + isFetching, isError: isLoadError, error: loadError, + refetch, } = useCustomerGetQuery({ id: customerId, enabled: Boolean(customerId) }); // 2) Estado de creación (mutación) @@ -225,8 +227,10 @@ export const useCustomerUpdateController = ( // carga de datos customerData, isLoading, + isFetching, isLoadError, loadError, + refetch, // mutation isUpdating, diff --git a/modules/customers/src/web/update/entities/customer-update-page-error-view-model.entity.ts b/modules/customers/src/web/update/entities/customer-update-page-error-view-model.entity.ts new file mode 100644 index 00000000..a77449d9 --- /dev/null +++ b/modules/customers/src/web/update/entities/customer-update-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface CustomerUpdatePageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} diff --git a/modules/customers/src/web/update/entities/index.ts b/modules/customers/src/web/update/entities/index.ts index 9446e1fb..623b99a7 100644 --- a/modules/customers/src/web/update/entities/index.ts +++ b/modules/customers/src/web/update/entities/index.ts @@ -1,3 +1,4 @@ export * from "./customer-update-form.entity"; export * from "./customer-update-form.schema"; export * from "./customer-update-patch.entity"; +export * from "./customer-update-page-error-view-model.entity"; diff --git a/modules/customers/src/web/update/ui/pages/customer-update-page.tsx b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx index c918094e..422119a5 100644 --- a/modules/customers/src/web/update/ui/pages/customer-update-page.tsx +++ b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx @@ -1,6 +1,5 @@ -import { ErrorAlert, NotFoundCard } from "@erp/core/components"; +import { ErrorAlert, PageErrorState } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; -import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { FormProvider } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -13,7 +12,8 @@ import { CustomerUpdateEditorForm } from "../editor"; export const CustomerUpdatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { updateCtrl, returnTo } = useCustomerUpdatePageController(); + const ctrl = useCustomerUpdatePageController(); + const { updateCtrl, returnTo } = ctrl; const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo, @@ -21,39 +21,52 @@ export const CustomerUpdatePage = () => { const handleCancel = () => navigateBack(); - if (updateCtrl.isLoading) { + if (ctrl.isInitialLoading) { return ; } - if (updateCtrl.isLoadError) { + if (ctrl.pageError) { return ( - - - -
- +
+
+
- +
); } - if (!updateCtrl.customerData) - return ( - - - - ); - return (
diff --git a/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.test.ts b/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.test.ts new file mode 100644 index 00000000..76d1fe4b --- /dev/null +++ b/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildCustomerUpdateMissingEntityPageErrorViewModel, + buildCustomerUpdatePageErrorViewModel, +} from "./build-customer-update-page-error-view-model"; + +const translate = (key: string) => + ({ + "update.page_error.notFound.title": "Cliente no encontrado", + "update.page_error.notFound.description": "El cliente ya no existe o no está disponible.", + "update.page_error.forbidden.title": "No tienes acceso a este cliente", + "update.page_error.forbidden.description": + "No dispones de permisos para editar este cliente.", + "update.page_error.network.title": "No se pudo conectar con el servidor", + "update.page_error.network.description": "Comprueba tu conexión e inténtalo de nuevo.", + "update.page_error.timeout.title": "La carga está tardando demasiado", + "update.page_error.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "update.page_error.server.title": "No se pudo cargar el cliente", + "update.page_error.server.description": + "Se produjo un error interno al recuperar el cliente.", + "update.page_error.unavailable.title": "Servicio temporalmente no disponible", + "update.page_error.unavailable.description": + "El servicio de clientes no está disponible en este momento.", + "update.page_error.unknown.title": "No se pudo cargar el cliente", + "update.page_error.unknown.description": + "Ha ocurrido un error inesperado al cargar el cliente.", + "update.page_error.missing_entity.title": "No se pudo cargar el cliente", + "update.page_error.missing_entity.description": + "La respuesta recibida no contiene un cliente válido.", + })[key] ?? key; + +test("mapea notFound real a notFound sin retry", () => { + const viewModel = buildCustomerUpdatePageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }), + translate + ); + + assert.equal(viewModel?.variant, "notFound"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-404"); +}); + +test("mapea forbidden sin retry", () => { + const viewModel = buildCustomerUpdatePageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); +}); + +test("mapea network, timeout, server, unavailable y unknown con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) { + const viewModel = buildCustomerUpdatePageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("ignora cancelled", () => { + const viewModel = buildCustomerUpdatePageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(viewModel, null); +}); + +test("trata una respuesta exitosa sin entidad como unknown por inconsistencia", () => { + const viewModel = buildCustomerUpdateMissingEntityPageErrorViewModel(translate); + + assert.equal(viewModel.variant, "unknown"); + assert.equal(viewModel.canRetry, true); + assert.equal(viewModel.description, "La respuesta recibida no contiene un cliente válido."); +}); diff --git a/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.ts b/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.ts new file mode 100644 index 00000000..14924aa4 --- /dev/null +++ b/modules/customers/src/web/update/utils/build-customer-update-page-error-view-model.ts @@ -0,0 +1,64 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { CustomerUpdatePageErrorViewModel } from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildErrorVariant = ( + error: unknown +): CustomerUpdatePageErrorViewModel["variant"] | "cancelled" => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return "cancelled"; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } +}; + +export const buildCustomerUpdatePageErrorViewModel = ( + error: unknown, + translate: Translate +): CustomerUpdatePageErrorViewModel | null => { + const variant = buildErrorVariant(error); + + if (variant === "cancelled") { + return null; + } + + return { + variant, + title: translate(`update.page_error.${variant}.title`), + description: translate(`update.page_error.${variant}.description`), + canRetry: !["forbidden", "notFound"].includes(variant), + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildCustomerUpdateMissingEntityPageErrorViewModel = ( + translate: Translate +): CustomerUpdatePageErrorViewModel => { + return { + variant: "unknown", + title: translate("update.page_error.missing_entity.title"), + description: translate("update.page_error.missing_entity.description"), + canRetry: true, + }; +}; diff --git a/modules/customers/src/web/update/utils/index.ts b/modules/customers/src/web/update/utils/index.ts index 872d2499..8a7b4400 100644 --- a/modules/customers/src/web/update/utils/index.ts +++ b/modules/customers/src/web/update/utils/index.ts @@ -1,3 +1,5 @@ export * from "./build-customer-update-default"; export * from "./build-customer-update-patch"; +export * from "./build-customer-update-page-error-view-model"; export * from "./build-update-customer-by-id-params"; +export * from "./resolve-customer-update-page-feedback-state"; diff --git a/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.test.ts b/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.test.ts new file mode 100644 index 00000000..fe5c000d --- /dev/null +++ b/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { resolveCustomerUpdatePageFeedbackState } from "./resolve-customer-update-page-feedback-state"; + +test("mantiene loading inicial mientras carga", () => { + const state = resolveCustomerUpdatePageFeedbackState({ + isLoading: true, + isLoadError: false, + hasCustomerData: false, + error: null, + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); +}); + +test("marca error bloqueante cuando falla sin datos utilizables", () => { + const state = resolveCustomerUpdatePageFeedbackState({ + isLoading: false, + isLoadError: true, + hasCustomerData: false, + error: new ApiClientError({ kind: "server", message: "technical" }), + }); + + assert.equal(state.hasBlockingError, true); + assert.equal(state.hasDataInconsistency, false); +}); + +test("no muestra pantalla bloqueante para cancelled", () => { + const state = resolveCustomerUpdatePageFeedbackState({ + isLoading: false, + isLoadError: true, + hasCustomerData: false, + error: new ApiClientError({ kind: "cancelled", message: "technical" }), + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); +}); + +test("detecta inconsistencia cuando la respuesta termina sin entidad", () => { + const state = resolveCustomerUpdatePageFeedbackState({ + isLoading: false, + isLoadError: false, + hasCustomerData: false, + error: null, + }); + + assert.equal(state.hasDataInconsistency, true); +}); diff --git a/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.ts b/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.ts new file mode 100644 index 00000000..166c2631 --- /dev/null +++ b/modules/customers/src/web/update/utils/resolve-customer-update-page-feedback-state.ts @@ -0,0 +1,29 @@ +import { isApiClientError } from "@erp/core/client"; + +interface ResolveCustomerUpdatePageFeedbackStateParams { + isLoading: boolean; + isLoadError: boolean; + hasCustomerData: boolean; + error: unknown; +} + +export interface CustomerUpdatePageFeedbackState { + isInitialLoading: boolean; + hasBlockingError: boolean; + hasDataInconsistency: boolean; +} + +export const resolveCustomerUpdatePageFeedbackState = ({ + isLoading, + isLoadError, + hasCustomerData, + error, +}: ResolveCustomerUpdatePageFeedbackStateParams): CustomerUpdatePageFeedbackState => { + const isCancelled = isApiClientError(error) && error.kind === "cancelled"; + + return { + isInitialLoading: isLoading || (isLoadError && isCancelled && !hasCustomerData), + hasBlockingError: isLoadError && !isCancelled && !hasCustomerData, + hasDataInconsistency: !isLoading && !isLoadError && !hasCustomerData, + }; +}; diff --git a/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts b/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts index a5c38b29..e4f73c03 100644 --- a/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts +++ b/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts @@ -1,18 +1,64 @@ +import { useCallback, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useUrlParamId } from "@erp/core/hooks"; -import { useSearchParams } from "react-router-dom"; + +import { useTranslation } from "../../../i18n"; +import { + buildCustomerViewMissingEntityPageErrorViewModel, + buildCustomerViewPageErrorViewModel, + resolveCustomerViewPageFeedbackState, +} from "../utils"; import { useCustomerViewController } from "./use-customer-view.controller"; export function useCustomerViewPageController() { + const { t } = useTranslation(); const customerId = useUrlParamId(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const viewCtrl = useCustomerViewController(customerId); const returnTo = searchParams.get("returnTo") ?? "/customers"; + const feedbackState = resolveCustomerViewPageFeedbackState({ + isLoading: viewCtrl.isLoading, + isLoadError: viewCtrl.isLoadError, + hasCustomerData: Boolean(viewCtrl.customerData), + error: viewCtrl.loadError, + }); + + const retryView = useCallback(async () => { + await viewCtrl.refetch(); + }, [viewCtrl]); + + const goHome = useCallback(() => { + navigate("/"); + }, [navigate]); + + const goBackToList = useCallback(() => { + navigate(returnTo); + }, [navigate, returnTo]); + + const pageError = useMemo(() => { + if (feedbackState.hasBlockingError) { + return buildCustomerViewPageErrorViewModel(viewCtrl.loadError, (key) => t(key)); + } + + if (feedbackState.hasDataInconsistency) { + return buildCustomerViewMissingEntityPageErrorViewModel((key) => t(key)); + } + + return null; + }, [feedbackState.hasBlockingError, feedbackState.hasDataInconsistency, t, viewCtrl.loadError]); return { viewCtrl, returnTo, + pageError, + retryView, + goHome, + goBackToList, + isRetrying: viewCtrl.isFetching && !viewCtrl.isLoading, + isInitialLoading: feedbackState.isInitialLoading, }; } diff --git a/modules/customers/src/web/view/controllers/use-customer-view.controller.ts b/modules/customers/src/web/view/controllers/use-customer-view.controller.ts index 5cc23f0b..8af8a714 100644 --- a/modules/customers/src/web/view/controllers/use-customer-view.controller.ts +++ b/modules/customers/src/web/view/controllers/use-customer-view.controller.ts @@ -4,6 +4,7 @@ export const useCustomerViewController = (customerId?: string) => { const { data: customerData, isLoading, + isFetching, isError: isLoadError, error: loadError, refetch, @@ -16,6 +17,7 @@ export const useCustomerViewController = (customerId?: string) => { // carga de datos customerData, isLoading, + isFetching, isLoadError, loadError, diff --git a/modules/customers/src/web/view/entities/customer-view-page-error-view-model.entity.ts b/modules/customers/src/web/view/entities/customer-view-page-error-view-model.entity.ts new file mode 100644 index 00000000..c1f8c28e --- /dev/null +++ b/modules/customers/src/web/view/entities/customer-view-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface CustomerViewPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} diff --git a/modules/customers/src/web/view/entities/index.ts b/modules/customers/src/web/view/entities/index.ts new file mode 100644 index 00000000..76238248 --- /dev/null +++ b/modules/customers/src/web/view/entities/index.ts @@ -0,0 +1 @@ +export * from "./customer-view-page-error-view-model.entity"; diff --git a/modules/customers/src/web/view/ui/pages/customer-view-page.tsx b/modules/customers/src/web/view/ui/pages/customer-view-page.tsx index d77f2495..5b9c9efd 100644 --- a/modules/customers/src/web/view/ui/pages/customer-view-page.tsx +++ b/modules/customers/src/web/view/ui/pages/customer-view-page.tsx @@ -1,6 +1,5 @@ -import { ErrorAlert, NotFoundCard } from "@erp/core/components"; +import { PageErrorState } from "@erp/core/components"; import { useReturnToNavigation } from "@erp/core/hooks"; -import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components"; import { Banknote, @@ -21,7 +20,8 @@ import { CustomerViewHeader } from "../blocks"; export const CustomerViewPage = () => { const { t } = useTranslation(); - const { viewCtrl, returnTo } = useCustomerViewPageController(); + const ctrl = useCustomerViewPageController(); + const { viewCtrl, returnTo } = ctrl; const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo, @@ -29,40 +29,52 @@ export const CustomerViewPage = () => { const handleCancel = () => navigateBack(); - if (viewCtrl.isLoading) { + if (ctrl.isInitialLoading) { return ; /*;*/ } - if (viewCtrl.isLoadError) { + if (ctrl.pageError) { return ( - <> - - +
+ - -
- -
- - +
+
); } - if (!viewCtrl.customerData) - return ( - - - - ); - return (
+ ({ + "view.page_error.notFound.title": "Cliente no encontrado", + "view.page_error.notFound.description": "El cliente ya no existe o no está disponible.", + "view.page_error.forbidden.title": "No tienes acceso a este cliente", + "view.page_error.forbidden.description": + "No dispones de permisos para consultar este cliente.", + "view.page_error.network.title": "No se pudo conectar con el servidor", + "view.page_error.network.description": "Comprueba tu conexión e inténtalo de nuevo.", + "view.page_error.timeout.title": "La carga está tardando demasiado", + "view.page_error.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "view.page_error.server.title": "No se pudo cargar el cliente", + "view.page_error.server.description": + "Se produjo un error interno al recuperar el cliente.", + "view.page_error.unavailable.title": "Servicio temporalmente no disponible", + "view.page_error.unavailable.description": + "El servicio de clientes no está disponible en este momento.", + "view.page_error.unknown.title": "No se pudo cargar el cliente", + "view.page_error.unknown.description": + "Ha ocurrido un error inesperado al cargar el cliente.", + "view.page_error.missing_entity.title": "No se pudo cargar el cliente", + "view.page_error.missing_entity.description": + "La respuesta recibida no contiene un cliente válido.", + })[key] ?? key; + +test("mapea notFound real a notFound sin retry", () => { + const viewModel = buildCustomerViewPageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }), + translate + ); + + assert.equal(viewModel?.variant, "notFound"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-404"); +}); + +test("mapea forbidden sin retry", () => { + const viewModel = buildCustomerViewPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); +}); + +test("mapea network, timeout, server, unavailable y unknown con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) { + const viewModel = buildCustomerViewPageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("ignora cancelled", () => { + const viewModel = buildCustomerViewPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(viewModel, null); +}); + +test("trata una respuesta exitosa sin entidad como unknown por inconsistencia", () => { + const viewModel = buildCustomerViewMissingEntityPageErrorViewModel(translate); + + assert.equal(viewModel.variant, "unknown"); + assert.equal(viewModel.canRetry, true); + assert.equal(viewModel.description, "La respuesta recibida no contiene un cliente válido."); +}); diff --git a/modules/customers/src/web/view/utils/build-customer-view-page-error-view-model.ts b/modules/customers/src/web/view/utils/build-customer-view-page-error-view-model.ts new file mode 100644 index 00000000..3ae0ea15 --- /dev/null +++ b/modules/customers/src/web/view/utils/build-customer-view-page-error-view-model.ts @@ -0,0 +1,64 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { CustomerViewPageErrorViewModel } from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildErrorVariant = ( + error: unknown +): CustomerViewPageErrorViewModel["variant"] | "cancelled" => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return "cancelled"; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } +}; + +export const buildCustomerViewPageErrorViewModel = ( + error: unknown, + translate: Translate +): CustomerViewPageErrorViewModel | null => { + const variant = buildErrorVariant(error); + + if (variant === "cancelled") { + return null; + } + + return { + variant, + title: translate(`view.page_error.${variant}.title`), + description: translate(`view.page_error.${variant}.description`), + canRetry: !["forbidden", "notFound"].includes(variant), + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildCustomerViewMissingEntityPageErrorViewModel = ( + translate: Translate +): CustomerViewPageErrorViewModel => { + return { + variant: "unknown", + title: translate("view.page_error.missing_entity.title"), + description: translate("view.page_error.missing_entity.description"), + canRetry: true, + }; +}; diff --git a/modules/customers/src/web/view/utils/index.ts b/modules/customers/src/web/view/utils/index.ts new file mode 100644 index 00000000..367c4412 --- /dev/null +++ b/modules/customers/src/web/view/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./build-customer-view-page-error-view-model"; +export * from "./resolve-customer-view-page-feedback-state"; diff --git a/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.test.ts b/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.test.ts new file mode 100644 index 00000000..0afd3ae2 --- /dev/null +++ b/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { resolveCustomerViewPageFeedbackState } from "./resolve-customer-view-page-feedback-state"; + +test("mantiene loading inicial mientras carga", () => { + const state = resolveCustomerViewPageFeedbackState({ + isLoading: true, + isLoadError: false, + hasCustomerData: false, + error: null, + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); +}); + +test("marca error bloqueante cuando falla sin datos utilizables", () => { + const state = resolveCustomerViewPageFeedbackState({ + isLoading: false, + isLoadError: true, + hasCustomerData: false, + error: new ApiClientError({ kind: "server", message: "technical" }), + }); + + assert.equal(state.hasBlockingError, true); + assert.equal(state.hasDataInconsistency, false); +}); + +test("no muestra pantalla bloqueante para cancelled", () => { + const state = resolveCustomerViewPageFeedbackState({ + isLoading: false, + isLoadError: true, + hasCustomerData: false, + error: new ApiClientError({ kind: "cancelled", message: "technical" }), + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); +}); + +test("detecta inconsistencia cuando la respuesta termina sin entidad", () => { + const state = resolveCustomerViewPageFeedbackState({ + isLoading: false, + isLoadError: false, + hasCustomerData: false, + error: null, + }); + + assert.equal(state.hasDataInconsistency, true); +}); diff --git a/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.ts b/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.ts new file mode 100644 index 00000000..9cdb1dae --- /dev/null +++ b/modules/customers/src/web/view/utils/resolve-customer-view-page-feedback-state.ts @@ -0,0 +1,29 @@ +import { isApiClientError } from "@erp/core/client"; + +interface ResolveCustomerViewPageFeedbackStateParams { + isLoading: boolean; + isLoadError: boolean; + hasCustomerData: boolean; + error: unknown; +} + +export interface CustomerViewPageFeedbackState { + isInitialLoading: boolean; + hasBlockingError: boolean; + hasDataInconsistency: boolean; +} + +export const resolveCustomerViewPageFeedbackState = ({ + isLoading, + isLoadError, + hasCustomerData, + error, +}: ResolveCustomerViewPageFeedbackStateParams): CustomerViewPageFeedbackState => { + const isCancelled = isApiClientError(error) && error.kind === "cancelled"; + + return { + isInitialLoading: isLoading || (isLoadError && isCancelled && !hasCustomerData), + hasBlockingError: isLoadError && !isCancelled && !hasCustomerData, + hasDataInconsistency: !isLoading && !isLoadError && !hasCustomerData, + }; +}; diff --git a/packages/rdx-ui/src/components/error-overlay.tsx b/packages/rdx-ui/src/components/error-overlay.tsx deleted file mode 100644 index b0c954e0..00000000 --- a/packages/rdx-ui/src/components/error-overlay.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Button } from "@repo/shadcn-ui/components"; -import { HomeIcon } from "lucide-react"; -import type { JSX } from "react"; -import { useNavigate } from "react-router-dom"; -import { BackHistoryButton } from "./buttons/back-history-button.tsx"; - -interface ErrorOverlayProps { - title?: string; - subtitle?: string; - description?: string; - - errorMessage?: string; - //errorStatusCode?: number | string | undefined; -} - -/*const _DrawByStatusCode = { - 0: UndrawAutumn, - 404: UndrawNoData, //UndrawEmpty, -};*/ - -export const ErrorOverlay = ({ - title = "Se ha producido un error", - subtitle = "Inténtalo de nuevo más tarde", - description = undefined, - errorMessage = undefined, -}: //errorStatusCode = undefined, -ErrorOverlayProps): JSX.Element => { - const navigate = useNavigate(); - /*const ErrorIllustration = () => - String(errorStatusCode).length - ? _DrawByStatusCode[String(errorStatusCode)] - : _DrawByStatusCode['0'];*/ - - return ( -
-
-

{title}

-

- {subtitle || errorMessage} -
- {description} -

-
- - -
-
-
- ); -}; - -ErrorOverlay.displayName = "ErrorOverlay"; diff --git a/packages/rdx-ui/src/components/index.ts b/packages/rdx-ui/src/components/index.ts index fc242c56..acac70a9 100644 --- a/packages/rdx-ui/src/components/index.ts +++ b/packages/rdx-ui/src/components/index.ts @@ -3,7 +3,6 @@ export * from "./custom-dialog.tsx"; export * from "./datatable/index.ts"; export * from "./dynamics-tabs.tsx"; export * from "./entity-sheet/index.ts"; -export * from "./error-overlay.tsx"; export * from "./field-value-list.ts"; export * from "./form/index.ts"; export * from "./full-screen-modal.tsx";