feat: add error handling and feedback state management for customer and proforma updates
- Implemented feedback state resolution for proforma list and update pages. - Created error view models for proforma and customer updates, including handling for various error types. - Added tests for error handling in proforma and customer list updates. - Introduced utility functions for building error view models and resolving feedback states. - Ensured translation support for error messages in Spanish. - Removed duplicate error alert components in customer module.
This commit is contained in:
parent
0be67ca6cd
commit
26e2ee59bd
@ -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
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
<DataSourceProvider dataSource={dataSource}>
|
||||
<IdentityAuthSessionProvider>
|
||||
<TooltipProvider>
|
||||
{/* Fallback Route */}
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<RenderErrorFallback
|
||||
onGoHome={() => {
|
||||
window.location.replace("/");
|
||||
}}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TooltipProvider>
|
||||
<Toaster expand={true} position="top-center" richColors />
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-4 rounded-md border bg-red-50 text-red-700" role="alert">
|
||||
<p className="font-medium">⚠️ Algo salió mal</p>
|
||||
<pre className="mt-2 text-sm whitespace-pre-wrap">{error?.message}</pre>
|
||||
<Button
|
||||
className="mt-3 px-3 py-1.5 rounded-md bg-red-600 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Fallback de sección (tarjeta compacta)
|
||||
*/
|
||||
export function SectionCardFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border shadow-sm p-5 bg-white" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 rounded-full p-2 bg-red-100">❗</div>
|
||||
<div className="grow">
|
||||
<h3 className="font-semibold text-gray-900">No se pudo cargar esta sección</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">{error?.message}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-gray-900 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md border"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Refrescar página
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Fallback pantalla completa
|
||||
*/
|
||||
export function FullPageFallback({ error }: FallbackProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-6 bg-gray-50 text-center">
|
||||
<div className="text-5xl">😵</div>
|
||||
<h1 className="mt-4 text-2xl font-bold text-gray-900">Ocurrió un error inesperado</h1>
|
||||
<p className="mt-2 text-gray-600">{error?.message}</p>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a className="px-4 py-2 rounded-md bg-blue-600 text-white" href="/">
|
||||
Volver al inicio
|
||||
</a>
|
||||
<Button className="px-4 py-2 rounded-md border" onClick={() => window.location.reload()}>
|
||||
Recargar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) Fallback para listas
|
||||
*/
|
||||
export function ListFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="p-4 rounded-md border bg-amber-50" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">🗂️</span>
|
||||
<div>
|
||||
<p className="font-medium text-amber-900">No pudimos cargar la lista.</p>
|
||||
<p className="text-sm text-amber-800 mt-1">{error?.message}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-amber-700 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md border"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Recargar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5) Fallback para formularios
|
||||
*/
|
||||
export function FormFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="rounded-md border p-4 bg-red-50" role="alert">
|
||||
<h4 className="font-semibold text-red-800">No se pudo mostrar el formulario</h4>
|
||||
<p className="mt-1 text-sm text-red-700">{error?.message}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-red-600 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button className="px-3 py-1.5 rounded-md border" onClick={() => history.back()}>
|
||||
Volver atrás
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="rounded-md border p-4 bg-blue-50 text-blue-900" role="alert">
|
||||
<div className="font-medium">No pudimos obtener los datos</div>
|
||||
<p className="mt-1 text-sm">{error?.message}</p>
|
||||
<p className="mt-1 text-xs opacity-80">{note}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-blue-700 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button className="px-3 py-1.5 rounded-md border" onClick={() => window.location.reload()}>
|
||||
Recargar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 7) Fallback modo desarrollador (detalle de stack)
|
||||
*/
|
||||
export function DevDetailsFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div className="rounded-md border p-4 bg-gray-50" role="alert">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-gray-900">Algo falló</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-gray-900 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button className="px-3 py-1.5 rounded-md border" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "Ocultar detalles" : "Ver detalles"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-3 text-xs whitespace-pre-wrap bg-white p-3 rounded-md border overflow-auto">
|
||||
{error?.stack || error?.message}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8) Fallback con soporte (link a ayuda)
|
||||
*/
|
||||
export function SupportFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="rounded-md border p-4 bg-white" role="alert">
|
||||
<h4 className="font-semibold text-gray-900">No pudimos completar la acción</h4>
|
||||
<p className="mt-1 text-gray-700">{error?.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="px-3 py-1.5 rounded-md bg-gray-900 text-white"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Intentar de nuevo
|
||||
</Button>
|
||||
<a className="px-3 py-1.5 rounded-md border" href="/ayuda">
|
||||
Ir a Ayuda
|
||||
</a>
|
||||
<a
|
||||
className="px-3 py-1.5 rounded-md border"
|
||||
href="mailto:soporte@tuapp.com?subject=Error%20en%20la%20aplicaci%C3%B3n"
|
||||
>
|
||||
Contactar soporte
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
export * from "./error-fallbacks";
|
||||
export * from "./slider-demo";
|
||||
|
||||
@ -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 (
|
||||
<AppMain className="h-dvh w-dvw">
|
||||
<Outlet />
|
||||
</AppMain>
|
||||
<AppMain className="h-dvh w-dvw">{children}</AppMain>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppFullscreenLayout = () => {
|
||||
return (
|
||||
<AppFullscreenLayoutFrame>
|
||||
<Outlet />
|
||||
</AppFullscreenLayoutFrame>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<SidebarProvider
|
||||
style={
|
||||
@ -19,10 +23,16 @@ export const AppSidebarLayout = () => {
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex h-screen min-w-0 flex-col overflow-hidden bg-background">
|
||||
<AppTopbar />
|
||||
<AppMain>
|
||||
<Outlet />
|
||||
</AppMain>
|
||||
<AppMain>{children}</AppMain>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppSidebarLayout = () => {
|
||||
return (
|
||||
<AppSidebarLayoutFrame>
|
||||
<Outlet />
|
||||
</AppSidebarLayoutFrame>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<main className="grid min-h-dvh bg-muted px-4 py-8">
|
||||
<section className="m-auto w-full max-w-md">
|
||||
<Outlet />
|
||||
</section>
|
||||
<section className="m-auto w-full max-w-md">{children}</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const AuthLayout = () => {
|
||||
return (
|
||||
<AuthLayoutFrame>
|
||||
<Outlet />
|
||||
</AuthLayoutFrame>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: <Revisar si cambia i18n>
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <Registro idempotente ligado a la instancia compartida>
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
|
||||
export const ErrorPage = () => {
|
||||
return (
|
||||
<div className='flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8'>
|
||||
<div className='max-w-md mx-auto text-center'>
|
||||
<div className='w-12 h-12 mx-auto text-primary' />
|
||||
<h1 className='mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl'>
|
||||
Oops, page not found!
|
||||
</h1>
|
||||
<p className='mt-4 text-muted-foreground'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-6'>
|
||||
<Button className='inline-flex items-center px-4 py-2 text-sm font-medium transition-colors rounded-md shadow-sm bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'>
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./error-page";
|
||||
export * from "./login-form";
|
||||
export * from "./render-error-fallback";
|
||||
export * from "./route-error-page";
|
||||
|
||||
17
apps/web/src/pages/render-error-fallback.test.tsx
Normal file
17
apps/web/src/pages/render-error-fallback.test.tsx
Normal file
@ -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(
|
||||
<RenderErrorFallback onGoHome={() => 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:/);
|
||||
});
|
||||
35
apps/web/src/pages/render-error-fallback.tsx
Normal file
35
apps/web/src/pages/render-error-fallback.tsx
Normal file
@ -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<FallbackProps, "resetErrorBoundary"> {
|
||||
onGoHome: () => void;
|
||||
}
|
||||
|
||||
export const RenderErrorFallback = ({
|
||||
onGoHome,
|
||||
resetErrorBoundary,
|
||||
}: RenderErrorFallbackProps) => {
|
||||
const { t } = useAppTranslation();
|
||||
|
||||
return (
|
||||
<main className="grid min-h-dvh bg-background px-4 py-8">
|
||||
<section className="m-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={t("renderErrors.unexpected.description")}
|
||||
primaryAction={{
|
||||
label: t("actions.retry"),
|
||||
onClick: resetErrorBoundary,
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: t("actions.goHome"),
|
||||
onClick: onGoHome,
|
||||
}}
|
||||
title={t("renderErrors.unexpected.title")}
|
||||
variant="unknown"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/pages/route-error-page.tsx
Normal file
103
apps/web/src/pages/route-error-page.tsx
Normal file
@ -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 (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={viewModel.description}
|
||||
primaryAction={
|
||||
actions.retry
|
||||
? {
|
||||
label: t("actions.retry"),
|
||||
onClick: actions.retry,
|
||||
}
|
||||
: {
|
||||
label: t("actions.goHome"),
|
||||
onClick: actions.goHome,
|
||||
}
|
||||
}
|
||||
secondaryAction={
|
||||
actions.retry
|
||||
? {
|
||||
label: t("actions.goHome"),
|
||||
onClick: actions.goHome,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={viewModel.title}
|
||||
variant={viewModel.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StaticRouteErrorPage = ({ variant }: Required<RouteErrorPageProps>) => {
|
||||
const { t } = useAppTranslation();
|
||||
const viewModel = resolveRouteErrorPageViewModel({
|
||||
variant,
|
||||
translate: t,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
navigate("/");
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={viewModel.description}
|
||||
primaryAction={{
|
||||
label: t("actions.goHome"),
|
||||
onClick: goHome,
|
||||
}}
|
||||
title={viewModel.title}
|
||||
variant={viewModel.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 = () => (
|
||||
<AuthLayoutFrame>
|
||||
<RouteErrorPage />
|
||||
</AuthLayoutFrame>
|
||||
);
|
||||
|
||||
const AppFullscreenRouteErrorElement = () => (
|
||||
<AppFullscreenLayoutFrame>
|
||||
<RouteErrorPage />
|
||||
</AppFullscreenLayoutFrame>
|
||||
);
|
||||
|
||||
const AppSidebarRouteErrorElement = () => (
|
||||
<AppSidebarLayoutFrame>
|
||||
<RouteErrorPage />
|
||||
</AppSidebarLayoutFrame>
|
||||
);
|
||||
|
||||
export const getAppRouter = () => {
|
||||
const params = {
|
||||
...import.meta.env,
|
||||
@ -44,6 +69,7 @@ export const getAppRouter = () => {
|
||||
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
errorElement: <AuthRouteErrorElement />,
|
||||
children: authRoutes,
|
||||
},
|
||||
|
||||
@ -63,11 +89,13 @@ export const getAppRouter = () => {
|
||||
|
||||
{
|
||||
element: <AppFullscreenLayout />,
|
||||
errorElement: <AppFullscreenRouteErrorElement />,
|
||||
children: appFullscreenRoutes,
|
||||
},
|
||||
|
||||
{
|
||||
element: <AppSidebarLayout />,
|
||||
errorElement: <AppSidebarRouteErrorElement />,
|
||||
children: [
|
||||
{
|
||||
path: "shadcnui",
|
||||
@ -79,19 +107,19 @@ export const getAppRouter = () => {
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
element: <ErrorPage />,
|
||||
element: <StaticRouteErrorPage variant="notFound" />,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <ErrorPage />,
|
||||
element: <StaticRouteErrorPage variant="notFound" />,
|
||||
},
|
||||
{
|
||||
path: "catalog",
|
||||
element: <ErrorPage />,
|
||||
element: <StaticRouteErrorPage variant="notFound" />,
|
||||
},
|
||||
{
|
||||
path: "quotes",
|
||||
element: <ErrorPage />,
|
||||
element: <StaticRouteErrorPage variant="notFound" />,
|
||||
},
|
||||
|
||||
...appSidebarRoutes,
|
||||
@ -100,7 +128,7 @@ export const getAppRouter = () => {
|
||||
|
||||
{
|
||||
path: "*",
|
||||
element: <ErrorPage />,
|
||||
element: <StaticRouteErrorPage variant="notFound" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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);
|
||||
});
|
||||
67
apps/web/src/routes/resolve-route-error-page-view-model.ts
Normal file
67
apps/web/src/routes/resolve-route-error-page-view-model.ts
Normal file
@ -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),
|
||||
};
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
361
docs/architecture/http-error-handling-frontend.md
Normal file
361
docs/architecture/http-error-handling-frontend.md
Normal file
@ -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
|
||||
```
|
||||
@ -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(
|
||||
<PageErrorState
|
||||
description="No se pudo cargar la proforma."
|
||||
primaryAction={{
|
||||
label: "Reintentar",
|
||||
onClick: () => 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(
|
||||
<PageErrorState
|
||||
description="La proforma ya no existe o no está disponible."
|
||||
title="Proforma no encontrada"
|
||||
variant="notFound"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Proforma no encontrada/);
|
||||
assert.doesNotMatch(html, /button/);
|
||||
assert.doesNotMatch(html, /Referencia:/);
|
||||
});
|
||||
@ -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<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: React.ReactNode;
|
||||
@ -25,50 +21,22 @@ export const ErrorState = ({
|
||||
...props
|
||||
}: ErrorStateProps) => {
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex min-h-64 flex-col items-center justify-center rounded-lg border border-destructive/30 bg-destructive/5 px-6 py-10 text-center",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
<PageErrorState
|
||||
className={className}
|
||||
description={description ?? ""}
|
||||
primaryAction={
|
||||
onRetry
|
||||
? {
|
||||
label: isRetrying ? "Reintentando…" : retryLabel,
|
||||
onClick: onRetry,
|
||||
isLoading: isRetrying,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
reference={reference}
|
||||
title={title}
|
||||
variant="unknown"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mb-4 flex size-11 items-center justify-center rounded-full bg-destructive/10 text-destructive"
|
||||
>
|
||||
<AlertCircle className="size-5" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
|
||||
{description ? (
|
||||
<div className="mt-2 max-w-xl text-sm text-muted-foreground">{description}</div>
|
||||
) : null}
|
||||
|
||||
{reference ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Referencia:{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.75rem] text-foreground">
|
||||
{reference}
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{onRetry ? (
|
||||
<Button
|
||||
className="mt-5"
|
||||
disabled={isRetrying}
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className={cn("size-4", isRetrying && "animate-spin")} />
|
||||
|
||||
<span aria-live="polite">{isRetrying ? "Reintentando…" : retryLabel}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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) => (
|
||||
<>
|
||||
<div className='rounded-lg border bg-card p-6'>
|
||||
<h3 className='text-lg font-semibold'>{title}</h3>
|
||||
<p className='text-sm text-muted-foreground'>{message}</p>
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-end'>
|
||||
<BackHistoryButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
103
modules/core/src/web/components/page-error-state.tsx
Normal file
103
modules/core/src/web/components/page-error-state.tsx
Normal file
@ -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<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface PageErrorStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
variant: PageErrorStateVariant;
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
primaryAction?: PageErrorAction;
|
||||
secondaryAction?: PageErrorAction;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
const VARIANT_STYLES: Record<PageErrorStateVariant, string> = {
|
||||
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 (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex min-h-64 flex-col items-center justify-center rounded-lg border px-6 py-10 text-center",
|
||||
VARIANT_STYLES[variant],
|
||||
className
|
||||
)}
|
||||
data-variant={variant}
|
||||
role="alert"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mb-4 flex size-11 items-center justify-center rounded-full bg-destructive/10 text-destructive"
|
||||
>
|
||||
<AlertCircle className="size-5" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<div className="mt-2 max-w-xl text-sm text-muted-foreground">{description}</div>
|
||||
|
||||
{reference ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Referencia:{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.75rem] text-foreground">
|
||||
{reference}
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{primaryAction || secondaryAction ? (
|
||||
<div className="mt-5 flex w-full max-w-md flex-col justify-center gap-3 sm:flex-row">
|
||||
{primaryAction ? (
|
||||
<Button disabled={primaryAction.isLoading} onClick={primaryAction.onClick} type="button">
|
||||
<span aria-live="polite">{primaryAction.label}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{secondaryAction ? (
|
||||
<Button
|
||||
disabled={secondaryAction.isLoading}
|
||||
onClick={secondaryAction.onClick}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<span aria-live="polite">{secondaryAction.label}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 = <TData>(status: number, data: TData): AxiosResponse<TData> => {
|
||||
return {
|
||||
config: createAxiosConfig(),
|
||||
data,
|
||||
headers: {},
|
||||
request: {},
|
||||
status,
|
||||
statusText: `${status}`,
|
||||
};
|
||||
};
|
||||
|
||||
const createAxiosHttpError = <TData>(
|
||||
status: number,
|
||||
data: TData,
|
||||
code?: string
|
||||
): AxiosError<TData> => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -11,6 +11,12 @@ export interface ICustomParams<T> {
|
||||
[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<R>(resource: string, params?: Record<string, unknown>): Promise<R>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2,6 +2,7 @@ import type {
|
||||
ApiProblemDetails,
|
||||
ApiProblemIssue,
|
||||
ApiProblemPathSegment,
|
||||
ApiProblemSource,
|
||||
} from "./api-problem-details";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@ -10,8 +11,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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<IssuedInvoiceStatusCounts>(() => {
|
||||
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,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issued-invoice-list-page-error-view-model.entity";
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<ErrorState
|
||||
description={listCtrl.errorMessage.description}
|
||||
onRetry={listCtrl.retry}
|
||||
reference={listCtrl.errorMessage.reference}
|
||||
title={listCtrl.errorMessage.title}
|
||||
/>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={pageError.description}
|
||||
primaryAction={
|
||||
pageError.canRetry
|
||||
? {
|
||||
label: isRetrying
|
||||
? t("pages.issued_invoices.list.actions.retrying")
|
||||
: t("pages.issued_invoices.list.actions.retry"),
|
||||
onClick: retryList,
|
||||
isLoading: isRetrying,
|
||||
}
|
||||
: {
|
||||
label: t("pages.issued_invoices.list.actions.go_home"),
|
||||
onClick: goHome,
|
||||
}
|
||||
}
|
||||
reference={pageError.reference}
|
||||
secondaryAction={
|
||||
pageError.canRetry
|
||||
? {
|
||||
label: t("pages.issued_invoices.list.actions.go_home"),
|
||||
onClick: goHome,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={pageError.title}
|
||||
variant={pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/*return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
|
||||
title={t("pages.issued_invoices.list.loadErrorTitle")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
);*/
|
||||
}
|
||||
|
||||
return (
|
||||
@ -77,6 +101,10 @@ export const ListIssuedInvoicesPage = () => {
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full space-y-4">
|
||||
{hasRefreshError && refreshError ? (
|
||||
<ErrorAlert message={refreshError.message} title={refreshError.title} />
|
||||
) : null}
|
||||
|
||||
<Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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.",
|
||||
});
|
||||
});
|
||||
@ -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"),
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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<string, string | number | null | undefined>) =>
|
||||
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,
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 <ProformaDetailSkeleton />;
|
||||
}
|
||||
|
||||
if (ctrl.state.isError) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-lg border bg-background p-6">
|
||||
<ErrorAlert
|
||||
message={ctrl.state.errorMessage ?? t("pages.proformas.update.load_error.message")}
|
||||
title={t("pages.proformas.update.load_error.title")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ctrl.state.isNotFound || !(ctrl.header && ctrl.general && ctrl.document)) {
|
||||
if (ctrl.state.pageError) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<NotFoundCard
|
||||
message={t("pages.proformas.update.not_found_msg")}
|
||||
title={t("pages.proformas.update.not_found_title")}
|
||||
<PageErrorState
|
||||
description={ctrl.state.pageError.description}
|
||||
primaryAction={ctrl.state.pageError.primaryAction}
|
||||
reference={ctrl.state.pageError.reference}
|
||||
secondaryAction={ctrl.state.pageError.secondaryAction}
|
||||
title={ctrl.state.pageError.title}
|
||||
variant={ctrl.state.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-list-page-error-view-model.entity";
|
||||
@ -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;
|
||||
}
|
||||
@ -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) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (listCtrl.isError) {
|
||||
if (pageError) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
|
||||
title={t("pages.proformas.list.loadErrorTitle")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={pageError.description}
|
||||
primaryAction={
|
||||
pageError.canRetry
|
||||
? {
|
||||
label: isRetrying
|
||||
? t("pages.proformas.list.actions.retrying")
|
||||
: t("pages.proformas.list.actions.retry"),
|
||||
onClick: retryList,
|
||||
isLoading: isRetrying,
|
||||
}
|
||||
: {
|
||||
label: t("pages.proformas.list.actions.go_home"),
|
||||
onClick: goHome,
|
||||
}
|
||||
}
|
||||
reference={pageError.reference}
|
||||
title={pageError.title}
|
||||
variant={pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -467,6 +489,8 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
title={t(scopeConfig.titleKey)}
|
||||
/>
|
||||
|
||||
{refreshError ? <ErrorAlert message={refreshError.message} title={refreshError.title} /> : null}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 hidden">
|
||||
<Card>
|
||||
|
||||
@ -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."
|
||||
);
|
||||
});
|
||||
@ -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"),
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||
|
||||
export interface ProformaUpdatePageErrorViewModel {
|
||||
variant: PageErrorStateVariant;
|
||||
title: string;
|
||||
description: string;
|
||||
canRetry: boolean;
|
||||
reference?: string;
|
||||
}
|
||||
@ -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 <ProformaUpdateSkeleton />;
|
||||
}
|
||||
|
||||
if (updateCtrl.isLoadError) {
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={
|
||||
updateCtrl.loadError instanceof Error
|
||||
? updateCtrl.loadError.message
|
||||
: t("pages.proformas.update.load_error.message", "Inténtalo de nuevo más tarde")
|
||||
}
|
||||
title={t("pages.proformas.update.load_error.title", "No se pudo cargar la proforma")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<BackHistoryButton onClick={() => navigateBack()} />
|
||||
<AppContent className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: ctrl.isRetrying
|
||||
? t("pages.proformas.update.page_error.actions.retrying")
|
||||
: t("pages.proformas.update.page_error.actions.retry"),
|
||||
onClick: ctrl.retryUpdate,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: ctrl.pageError.variant === "forbidden"
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.go_home"),
|
||||
onClick: ctrl.goHome,
|
||||
}
|
||||
: {
|
||||
label: t("pages.proformas.update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
secondaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!updateCtrl.proformaData)
|
||||
return (
|
||||
<AppContent>
|
||||
<NotFoundCard
|
||||
message={t(
|
||||
"pages.proformas.update.not_found_msg",
|
||||
"Revisa el identificador o vuelve al listado."
|
||||
)}
|
||||
title={t("pages.proformas.update.not_found_title", "Proforma no encontrada")}
|
||||
/>
|
||||
</AppContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||
<FormProvider {...updateCtrl.form}>
|
||||
|
||||
@ -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.");
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
1
modules/customers/src/web/list/entities/index.ts
Normal file
1
modules/customers/src/web/list/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-list-page-error-view-model.entity";
|
||||
@ -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 = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (listCtrl.isError) {
|
||||
if (pageError) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(listCtrl.error as Error)?.message || t("list.errors.load_message")}
|
||||
title={t("list.errors.load_title")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={pageError.description}
|
||||
primaryAction={
|
||||
pageError.canRetry
|
||||
? {
|
||||
label: isRetrying ? t("list.actions.retrying") : t("list.actions.retry"),
|
||||
onClick: retryList,
|
||||
isLoading: isRetrying,
|
||||
}
|
||||
: {
|
||||
label: t("list.actions.go_home"),
|
||||
onClick: goHome,
|
||||
}
|
||||
}
|
||||
reference={pageError.reference}
|
||||
title={pageError.title}
|
||||
variant={pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -117,6 +131,8 @@ export const ListCustomersPage = () => {
|
||||
title={t("list.title")}
|
||||
/>
|
||||
|
||||
{refreshError ? <ErrorAlert message={refreshError.message} title={refreshError.title} /> : null}
|
||||
|
||||
{/* Table */}
|
||||
{isPanelOpen ? (
|
||||
<ResizablePanelGroup
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ApiClientError } from "@erp/core/client";
|
||||
|
||||
import {
|
||||
buildCustomerListPageErrorViewModel,
|
||||
buildCustomerListRefreshErrorViewModel,
|
||||
} from "./build-customer-list-page-error-view-model";
|
||||
|
||||
const translate = (key: string) =>
|
||||
({
|
||||
"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."
|
||||
);
|
||||
});
|
||||
@ -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"),
|
||||
};
|
||||
};
|
||||
@ -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/);
|
||||
});
|
||||
2
modules/customers/src/web/list/utils/index.ts
Normal file
2
modules/customers/src/web/list/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./build-customer-list-page-error-view-model";
|
||||
export * from "./resolve-customer-list-feedback-state";
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
// components/ErrorAlert.tsx
|
||||
interface ErrorAlertProps {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const ErrorAlert = ({ title, message }: ErrorAlertProps) => (
|
||||
<div
|
||||
className='mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'
|
||||
role='alert'
|
||||
aria-live='assertive'
|
||||
>
|
||||
<p className='font-semibold text-destructive-foreground'>{title}</p>
|
||||
<p className='text-sm text-destructive-foreground/90'>{message}</p>
|
||||
</div>
|
||||
);
|
||||
@ -1,2 +1 @@
|
||||
export * from "./customer-status-badge";
|
||||
export * from "./error-alert";
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||
|
||||
export interface CustomerUpdatePageErrorViewModel {
|
||||
variant: PageErrorStateVariant;
|
||||
title: string;
|
||||
description: string;
|
||||
canRetry: boolean;
|
||||
reference?: string;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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 <CustomerEditorSkeleton />;
|
||||
}
|
||||
|
||||
if (updateCtrl.isLoadError) {
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={
|
||||
updateCtrl.loadError instanceof Error
|
||||
? updateCtrl.loadError.message
|
||||
: t("update.errors.load_message")
|
||||
}
|
||||
title={t("update.errors.load_title")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<BackHistoryButton />
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: ctrl.isRetrying
|
||||
? t("update.page_error.actions.retrying")
|
||||
: t("update.page_error.actions.retry"),
|
||||
onClick: ctrl.retryUpdate,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: ctrl.pageError.variant === "forbidden"
|
||||
? {
|
||||
label: t("update.page_error.actions.go_home"),
|
||||
onClick: ctrl.goHome,
|
||||
}
|
||||
: {
|
||||
label: t("update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
secondaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: t("update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!updateCtrl.customerData)
|
||||
return (
|
||||
<AppContent>
|
||||
<NotFoundCard
|
||||
message={t("update.errors.not_found_message")}
|
||||
title={t("update.errors.not_found_title")}
|
||||
/>
|
||||
</AppContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||
<FormProvider {...updateCtrl.form}>
|
||||
|
||||
@ -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.");
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||
|
||||
export interface CustomerViewPageErrorViewModel {
|
||||
variant: PageErrorStateVariant;
|
||||
title: string;
|
||||
description: string;
|
||||
canRetry: boolean;
|
||||
reference?: string;
|
||||
}
|
||||
1
modules/customers/src/web/view/entities/index.ts
Normal file
1
modules/customers/src/web/view/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-view-page-error-view-model.entity";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user