Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ features/[nombre]/
| `home` | Landing de la app (`/`) |
| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` |
| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar |
| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta |
| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) |
| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) |

Expand Down
1 change: 1 addition & 0 deletions src/app/router/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('navRoutesFor', () => {
'/orders',
'/inventory',
'/warehouses',
'/failed-events',
'/reports',
])
})
Expand Down
9 changes: 9 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import HomeIcon from '@mui/icons-material/Home'
import HubOutlinedIcon from '@mui/icons-material/HubOutlined'
import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined'
Expand Down Expand Up @@ -46,6 +47,9 @@ const ProductDetailPage = lazy(() =>
const WarehousesPage = lazy(() =>
import('features/warehouses').then((m) => ({ default: m.WarehousesPage })),
)
const FailedEventsPage = lazy(() =>
import('features/failed-events').then((m) => ({ default: m.FailedEventsPage })),
)
const ReportsPage = lazy(() => import('features/reports').then((m) => ({ default: m.ReportsPage })))
const LoginPage = lazy(() => import('features/auth').then((m) => ({ default: m.LoginPage })))

Expand Down Expand Up @@ -151,6 +155,11 @@ export const appRoutes: AppRoute[] = [
element: <WarehousesPage />,
nav: { label: 'Depósitos', icon: <WarehouseOutlinedIcon /> },
},
{
path: '/failed-events',
element: <FailedEventsPage />,
nav: { label: 'Eventos fallidos', icon: <ErrorOutlineIcon /> },
},
{
path: '/reports',
element: <ReportsPage />,
Expand Down
88 changes: 88 additions & 0 deletions src/features/failed-events/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { client } from 'shared/api/client'

import type {
FailedEvent,
FailedEventAction,
FailedEventDirection,
FailedEventFilters,
FailedEventPage,
FailedEventStatus,
} from './types'

// Frontera con `/api/v1/failed-events`. Ningún componente ve el snake_case.

interface ApiFailedEvent {
id: number
event_type: string
direction: FailedEventDirection
status: FailedEventStatus
attempts: number
max_attempts: number
next_retry_at: string | null
last_error: string | null
last_response_status: number | null
created_at: string
}

interface ApiList<T> {
data: T[]
meta: { page: number; per_page: number; total: number }
}

function toFailedEvent(event: ApiFailedEvent): FailedEvent {
return {
id: event.id,
eventType: event.event_type,
direction: event.direction,
status: event.status,
attempts: event.attempts,
maxAttempts: event.max_attempts,
nextRetryAt: event.next_retry_at,
lastError: event.last_error,
lastResponseStatus: event.last_response_status,
createdAt: event.created_at,
}
}

// Sin estado no viaja `status=`: la API ignora un estado desconocido, pero uno
// vacío no es "todos" por contrato, es por casualidad.
function toParams({ page, perPage, status }: FailedEventFilters) {
return { page, per_page: perPage, ...(status === undefined ? {} : { status }) }
}

export async function fetchFailedEventPage(filters: FailedEventFilters): Promise<FailedEventPage> {
const { data } = await client.get<ApiList<ApiFailedEvent>>('/failed-events', {
params: toParams(filters),
})

return {
events: data.data.map(toFailedEvent),
page: data.meta.page,
perPage: data.meta.per_page,
total: data.meta.total,
}
}

/** Cuántos eventos hay en un estado, sin traerlos: una fila y el `meta.total`. */
export async function fetchFailedEventCount(status?: FailedEventStatus): Promise<number> {
const { data } = await client.get<ApiList<ApiFailedEvent>>('/failed-events', {
params: toParams({ page: 1, perPage: 1, status }),
})

return data.meta.total
}

/**
* Reintento o descarte manual. El reintento vuelve el evento a `pending` con
* los intentos en cero y lo encola ya, sin esperar al barrido; responde **422**
* si el evento no se puede reintentar (resuelto, o en proceso con un worker
* vivo). El descarte lo saca de la cola para siempre.
*/
export async function actOnFailedEvent(
id: number,
action: FailedEventAction,
): Promise<FailedEvent> {
const { data } = await client.post<ApiFailedEvent>(`/failed-events/${id}/${action}`)

return toFailedEvent(data)
}
107 changes: 107 additions & 0 deletions src/features/failed-events/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { StatusVariant } from 'shared/components'

import type { FailedEventDirection, FailedEventStatus } from './types'

// Copy centralizado de la feature — sin literales sueltos en el JSX.

const numberFormat = new Intl.NumberFormat('es-AR')

export function formatCount(value: number): string {
return numberFormat.format(value)
}

/**
* Nombre legible de cada tipo de evento (`Webhooks::ReplayRegistry`). Un tipo
* que no está acá se muestra tal cual: es mejor un nombre técnico que
* esconder que existe.
*/
export const EVENT_TYPE_LABELS: Record<string, string> = {
'integrations.http_request': 'Pedido a un canal o courier',
'webhooks.order_ingestion': 'Venta recibida de un canal',
'webhooks.tracking_ingestion': 'Novedad de seguimiento de un envío',
}

export const STATUS_LABELS: Record<FailedEventStatus, string> = {
pending: 'Pendiente',
processing: 'Procesando',
succeeded: 'Resuelto',
dead: 'Agotado',
discarded: 'Descartado',
}

/** El color nunca va solo: el badge siempre lleva el texto del estado. */
export const STATUS_VARIANTS: Record<FailedEventStatus, StatusVariant> = {
pending: 'warning',
processing: 'info',
succeeded: 'success',
dead: 'error',
discarded: 'neutral',
}

export const DIRECTION_LABELS: Record<FailedEventDirection, string> = {
inbound: 'Entrante',
outbound: 'Saliente',
}

export const failedEventsCopy = {
page: {
title: 'Eventos fallidos',
subtitle:
'Webhooks y sincronizaciones que fallaron. El sistema los reintenta solo; acá podés forzar un reintento o descartarlos.',
tableLabel: 'Cola de eventos fallidos',
empty: 'No hay eventos en esta pestaña.',
error: 'No pudimos cargar la cola de eventos.',
},
tabs: {
all: 'Todos',
pending: 'Pendientes',
dead: 'Agotados',
succeeded: 'Resueltos',
discarded: 'Descartados',
},
columns: {
event: 'Evento',
status: 'Estado',
attempts: 'Intentos',
nextRetry: 'Próximo reintento',
lastError: 'Último error',
createdAt: 'Registrado',
actions: 'Acciones',
},
cells: {
attempts: (attempts: number, max: number) => `${attempts} / ${max}`,
noRetry: '—',
noError: 'Sin detalle',
httpStatus: (status: number) => `HTTP ${status}`,
/** Si el reintento ya venció, el barrido lo toma en el próximo minuto. */
due: 'En la próxima vuelta',
},
actions: {
retry: 'Reintentar',
discard: 'Descartar',
retryFor: (id: number) => `Reintentar el evento ${id}`,
discardFor: (id: number) => `Descartar el evento ${id}`,
},
discard: {
title: 'Descartar evento',
body: (label: string) =>
`«${label}» deja de reintentarse. Si la causa ya se resolvió, conviene reintentarlo en vez de descartarlo.`,
confirm: 'Descartar',
cancel: 'Volver',
close: 'Cerrar',
},
feedback: {
retried: 'El evento volvió a la cola y se reintenta ahora.',
discarded: 'Evento descartado.',
notRequeueable:
'Ese evento ya no admite reintento: cambió de estado. La tabla está actualizada.',
failed: 'No pudimos actualizar el evento. Probá de nuevo.',
},
pagination: {
previous: 'Página anterior',
next: 'Página siguiente',
page: (page: number) => `Ir a la página ${page}`,
summary: (from: number, to: number, total: number) =>
`Mostrando ${from} a ${to} de ${formatCount(total)} ${total === 1 ? 'evento' : 'eventos'}`,
},
}
71 changes: 71 additions & 0 deletions src/features/failed-events/hooks/useFailedEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
keepPreviousData,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import type { ApiRequestError } from 'shared/api/types'

import { actOnFailedEvent, fetchFailedEventCount, fetchFailedEventPage } from '../api'
import { failedEventKeys } from '../queryKeys'
import type {
FailedEvent,
FailedEventAction,
FailedEventFilters,
FailedEventPage,
FailedEventStatus,
} from '../types'

/**
* Las pestañas, en orden, con el estado que filtra cada una. `processing` no
* tiene pestaña: es un estado de segundos mientras un worker lo intenta, y
* aparece igual en «Todos».
*/
export const QUEUE_TABS = [
{ id: 'all', status: undefined },
{ id: 'pending', status: 'pending' },
{ id: 'dead', status: 'dead' },
{ id: 'succeeded', status: 'succeeded' },
{ id: 'discarded', status: 'discarded' },
] as const satisfies readonly { id: string; status: FailedEventStatus | undefined }[]

export type QueueTabId = (typeof QUEUE_TABS)[number]['id']

/** Status con el que la API rechaza reintentar un evento que no admite reintento. */
export const NOT_REQUEUEABLE_STATUS = 422

export function useFailedEventPage(filters: FailedEventFilters) {
return useQuery<FailedEventPage>({
queryKey: failedEventKeys.page(filters),
queryFn: () => fetchFailedEventPage(filters),
// Cambiar de página o de pestaña no vacía la tabla mientras responde la API.
placeholderData: keepPreviousData,
})
}

/** Los contadores de las pestañas, en paralelo. Uno que falla deja la pestaña sin número. */
export function useFailedEventCounts() {
return useQueries({
queries: QUEUE_TABS.map(({ status }) => ({
queryKey: failedEventKeys.count(status),
queryFn: () => fetchFailedEventCount(status),
})),
combine: (results) => results.map((result) => result.data),
})
}

/**
* Reintento o descarte. Invalida la feature también al fallar: el caso real de
* error es un evento que cambió de estado mientras la tabla estaba abierta (el
* barrido lo resolvió, otro operador lo descartó), y la fila tiene que
* mostrarlo.
*/
export function useFailedEventAction() {
const queryClient = useQueryClient()

return useMutation<FailedEvent, ApiRequestError, { id: number; action: FailedEventAction }>({
mutationFn: ({ id, action }) => actOnFailedEvent(id, action),
onSettled: () => queryClient.invalidateQueries({ queryKey: failedEventKeys.all }),
})
}
1 change: 1 addition & 0 deletions src/features/failed-events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FailedEventsPage } from './pages/FailedEventsPage'
Loading
Loading