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:
David Arranz 2026-08-06 13:03:48 +02:00
parent 0be67ca6cd
commit 26e2ee59bd
108 changed files with 4241 additions and 687 deletions

View File

@ -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

View File

@ -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} />}

View File

@ -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>
);
}

View File

@ -1,2 +1 @@
export * from "./error-fallbacks";
export * from "./slider-demo";

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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,
});
};

View File

@ -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>
);
};

View File

@ -1,2 +1,3 @@
export * from "./error-page";
export * from "./login-form";
export * from "./render-error-fallback";
export * from "./route-error-page";

View 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:/);
});

View 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>
);
};

View 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>
);
};

View File

@ -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" />,
},
],
},

View File

@ -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);
});

View 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),
};
};

View File

@ -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)

View File

@ -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)

View 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
```

View File

@ -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:/);
});

View File

@ -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>
/>
);
};

View File

@ -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";

View File

@ -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>
</>
);

View 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>
);
};

View File

@ -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");
});
});

View File

@ -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);
};

View File

@ -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;
}

View File

@ -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>;

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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",

View File

@ -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
);
};

View File

@ -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 @@
}
}
}
}
}

View File

@ -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 @@
}
}
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -0,0 +1 @@
export * from "./issued-invoice-list-page-error-view-model.entity";

View File

@ -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;
}

View File

@ -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>

View File

@ -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.",
});
});

View File

@ -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"),
};
};

View File

@ -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,
}
);
});

View File

@ -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,
};
};

View File

@ -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;

View File

@ -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,
},

View File

@ -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 {

View File

@ -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>

View File

@ -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");
});

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -0,0 +1 @@
export * from "./proforma-list-page-error-view-model.entity";

View File

@ -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;
}

View File

@ -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>

View File

@ -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."
);
});

View File

@ -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"),
};
};

View File

@ -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);
});

View File

@ -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,
};
};

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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";

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface ProformaUpdatePageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
canRetry: boolean;
reference?: string;
}

View File

@ -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}>

View File

@ -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.");
});

View File

@ -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,
};
};

View File

@ -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";

View File

@ -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,
}
);
});

View File

@ -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,
};
};

View File

@ -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.",

View File

@ -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.",

View File

@ -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,
};
};

View File

@ -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,

View File

@ -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;
}

View File

@ -0,0 +1 @@
export * from "./customer-list-page-error-view-model.entity";

View File

@ -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

View File

@ -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."
);
});

View File

@ -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"),
};
};

View File

@ -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/);
});

View File

@ -0,0 +1,2 @@
export * from "./build-customer-list-page-error-view-model";
export * from "./resolve-customer-list-feedback-state";

View File

@ -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);
});

View File

@ -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,
};
};

View File

@ -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>
);

View File

@ -1,2 +1 @@
export * from "./customer-status-badge";
export * from "./error-alert";

View File

@ -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,
};
};

View File

@ -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,

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface CustomerUpdatePageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
canRetry: boolean;
reference?: string;
}

View File

@ -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";

View File

@ -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}>

View File

@ -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.");
});

View File

@ -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,
};
};

View File

@ -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";

View File

@ -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);
});

View File

@ -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,
};
};

View File

@ -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,
};
}

View File

@ -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,

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface CustomerViewPageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
canRetry: boolean;
reference?: string;
}

View 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