diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md
index 660a1a7..d84b845 100644
--- a/docs/guidelines/architecture.md
+++ b/docs/guidelines/architecture.md
@@ -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`) |
diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx
index 0702781..661b9f2 100644
--- a/src/app/router/routes.test.tsx
+++ b/src/app/router/routes.test.tsx
@@ -30,6 +30,7 @@ describe('navRoutesFor', () => {
'/orders',
'/inventory',
'/warehouses',
+ '/failed-events',
'/reports',
])
})
diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx
index cfcae09..061841f 100644
--- a/src/app/router/routes.tsx
+++ b/src/app/router/routes.tsx
@@ -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'
@@ -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 })))
@@ -151,6 +155,11 @@ export const appRoutes: AppRoute[] = [
element: ,
nav: { label: 'Depósitos', icon: },
},
+ {
+ path: '/failed-events',
+ element: ,
+ nav: { label: 'Eventos fallidos', icon: },
+ },
{
path: '/reports',
element: ,
diff --git a/src/features/failed-events/api.ts b/src/features/failed-events/api.ts
new file mode 100644
index 0000000..2e4734c
--- /dev/null
+++ b/src/features/failed-events/api.ts
@@ -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 {
+ 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 {
+ const { data } = await client.get>('/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 {
+ const { data } = await client.get>('/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 {
+ const { data } = await client.post(`/failed-events/${id}/${action}`)
+
+ return toFailedEvent(data)
+}
diff --git a/src/features/failed-events/content.ts b/src/features/failed-events/content.ts
new file mode 100644
index 0000000..bcda153
--- /dev/null
+++ b/src/features/failed-events/content.ts
@@ -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 = {
+ '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 = {
+ 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 = {
+ pending: 'warning',
+ processing: 'info',
+ succeeded: 'success',
+ dead: 'error',
+ discarded: 'neutral',
+}
+
+export const DIRECTION_LABELS: Record = {
+ 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'}`,
+ },
+}
diff --git a/src/features/failed-events/hooks/useFailedEvents.ts b/src/features/failed-events/hooks/useFailedEvents.ts
new file mode 100644
index 0000000..ff1af22
--- /dev/null
+++ b/src/features/failed-events/hooks/useFailedEvents.ts
@@ -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({
+ 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({
+ mutationFn: ({ id, action }) => actOnFailedEvent(id, action),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: failedEventKeys.all }),
+ })
+}
diff --git a/src/features/failed-events/index.ts b/src/features/failed-events/index.ts
new file mode 100644
index 0000000..63f9838
--- /dev/null
+++ b/src/features/failed-events/index.ts
@@ -0,0 +1 @@
+export { FailedEventsPage } from './pages/FailedEventsPage'
diff --git a/src/features/failed-events/pages/FailedEventsPage.test.tsx b/src/features/failed-events/pages/FailedEventsPage.test.tsx
new file mode 100644
index 0000000..ff9f574
--- /dev/null
+++ b/src/features/failed-events/pages/FailedEventsPage.test.tsx
@@ -0,0 +1,141 @@
+import { fireEvent, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { renderWithTheme } from '../../../test/renderWithTheme'
+import type * as queueHooks from '../hooks/useFailedEvents'
+import {
+ useFailedEventAction,
+ useFailedEventCounts,
+ useFailedEventPage,
+} from '../hooks/useFailedEvents'
+import type { FailedEvent } from '../types'
+
+import { FailedEventsPage } from './FailedEventsPage'
+
+vi.mock('../hooks/useFailedEvents', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFailedEventPage: vi.fn(),
+ useFailedEventCounts: vi.fn(),
+ useFailedEventAction: vi.fn(),
+}))
+
+const FETCHED_AT = new Date('2026-10-02T12:00:00Z').getTime()
+
+const DEAD: FailedEvent = {
+ id: 11,
+ eventType: 'webhooks.order_ingestion',
+ direction: 'inbound',
+ status: 'dead',
+ attempts: 5,
+ maxAttempts: 5,
+ nextRetryAt: null,
+ lastError: 'product not found for external id 123',
+ lastResponseStatus: null,
+ createdAt: '2026-10-02T10:00:00Z',
+}
+
+const PENDING: FailedEvent = {
+ ...DEAD,
+ id: 12,
+ eventType: 'integrations.http_request',
+ direction: 'outbound',
+ status: 'pending',
+ attempts: 2,
+ nextRetryAt: '2026-10-02T12:04:00Z',
+ lastError: 'Service Unavailable',
+ lastResponseStatus: 503,
+}
+
+const SUCCEEDED: FailedEvent = { ...DEAD, id: 13, status: 'succeeded', lastError: null }
+
+const mutate = vi.fn()
+
+function showEvents(events: FailedEvent[]) {
+ vi.mocked(useFailedEventPage).mockReturnValue({
+ data: { events, page: 1, perPage: 20, total: events.length },
+ dataUpdatedAt: FETCHED_AT,
+ isPending: false,
+ isError: false,
+ } as never)
+}
+
+beforeEach(() => {
+ mutate.mockReset()
+ showEvents([DEAD, PENDING, SUCCEEDED])
+ vi.mocked(useFailedEventCounts).mockReturnValue([3, 1, 1, 1, 0])
+ vi.mocked(useFailedEventAction).mockReturnValue({ mutate, isPending: false } as never)
+})
+
+const row = (text: string) => {
+ const cell = screen.getAllByText(text)[0]
+ const tableRow = cell?.closest('tr')
+ if (tableRow === null || tableRow === undefined) throw new Error(`no row for ${text}`)
+ return within(tableRow)
+}
+
+describe('FailedEventsPage', () => {
+ it('names each event in words with its direction, status and attempts', () => {
+ renderWithTheme()
+
+ const pending = row('Pedido a un canal o courier')
+ expect(pending.getByText('Saliente')).toBeInTheDocument()
+ expect(pending.getByText('Pendiente')).toBeInTheDocument()
+ expect(pending.getByText('2 / 5')).toBeInTheDocument()
+ })
+
+ it('shows the last error with the HTTP status of the answer', () => {
+ renderWithTheme()
+
+ expect(row('Service Unavailable').getByText('HTTP 503')).toBeInTheDocument()
+ })
+
+ it('says when the next automatic retry is due', () => {
+ renderWithTheme()
+
+ expect(row('Service Unavailable').getByText('dentro de 4 minutos')).toBeInTheDocument()
+ })
+
+ it('shows the count of each tab', () => {
+ renderWithTheme()
+
+ expect(screen.getByRole('tab', { name: /Agotados/ })).toHaveTextContent('1')
+ })
+
+ it('retries an exhausted event right away', () => {
+ renderWithTheme()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reintentar el evento 11' }))
+
+ expect(mutate).toHaveBeenCalledWith({ id: 11, action: 'retry' }, expect.anything())
+ })
+
+ it('asks before discarding and then discards', () => {
+ renderWithTheme()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Descartar el evento 12' }))
+ fireEvent.click(
+ within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Descartar' }),
+ )
+
+ expect(mutate).toHaveBeenCalledWith({ id: 12, action: 'discard' }, expect.anything())
+ })
+
+ it('offers no action on an event that already went through', () => {
+ renderWithTheme()
+
+ expect(screen.queryByRole('button', { name: 'Reintentar el evento 13' })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Descartar el evento 13' })).toBeNull()
+ })
+
+ it('asks the API for the status of the chosen tab, from the first page', () => {
+ renderWithTheme()
+
+ fireEvent.click(screen.getByRole('tab', { name: /Agotados/ }))
+
+ expect(vi.mocked(useFailedEventPage)).toHaveBeenLastCalledWith({
+ page: 1,
+ perPage: 20,
+ status: 'dead',
+ })
+ })
+})
diff --git a/src/features/failed-events/pages/FailedEventsPage.tsx b/src/features/failed-events/pages/FailedEventsPage.tsx
new file mode 100644
index 0000000..9f786af
--- /dev/null
+++ b/src/features/failed-events/pages/FailedEventsPage.tsx
@@ -0,0 +1,246 @@
+import { Box, Button, Stack, Typography } from '@mui/material'
+import { useMemo, useState } from 'react'
+import {
+ ConfirmDialog,
+ DataTable,
+ ErrorFallback,
+ LoadingSpinner,
+ PageWrapper,
+ StackedCell,
+ StatusBadge,
+} from 'shared/components'
+import type { DataTableColumn, DataTableTab } from 'shared/components'
+import { notify } from 'shared/store'
+import { formatRelativeTime } from 'shared/utils'
+
+import {
+ DIRECTION_LABELS,
+ STATUS_LABELS,
+ STATUS_VARIANTS,
+ failedEventsCopy,
+ formatCount,
+} from '../content'
+import {
+ NOT_REQUEUEABLE_STATUS,
+ QUEUE_TABS,
+ useFailedEventAction,
+ useFailedEventCounts,
+ useFailedEventPage,
+} from '../hooks/useFailedEvents'
+import type { QueueTabId } from '../hooks/useFailedEvents'
+import type { FailedEvent, FailedEventAction } from '../types'
+import { actionsFor, eventLabel } from '../utils/actions'
+
+const {
+ page,
+ tabs: tabCopy,
+ columns,
+ cells,
+ actions: actionCopy,
+ discard,
+ feedback,
+} = failedEventsCopy
+
+const PER_PAGE = 20
+
+function nextRetryCell(event: FailedEvent, now: Date): string {
+ if (event.nextRetryAt === null) return cells.noRetry
+ if (new Date(event.nextRetryAt).getTime() <= now.getTime()) return cells.due
+
+ return formatRelativeTime(event.nextRetryAt, now) ?? cells.noRetry
+}
+
+function lastErrorCell(event: FailedEvent) {
+ const status =
+ event.lastResponseStatus === null ? undefined : cells.httpStatus(event.lastResponseStatus)
+
+ return
+}
+
+/**
+ * Cola de eventos fallidos (Dead Letter Queue, RF-16): lo que el motor de
+ * reintentos todavía intenta, lo que agotó sus intentos y lo que se resolvió.
+ *
+ * No tiene maqueta en `docs/design/`: es la tabla del DS con las pestañas por
+ * estado del catálogo. Existe para que la resiliencia del sistema se pueda ver
+ * y operar, no sólo confiar en que pasa.
+ */
+export function FailedEventsPage() {
+ const [tabId, setTabId] = useState('all')
+ const [pageNumber, setPageNumber] = useState(1)
+ const [discarding, setDiscarding] = useState(undefined)
+
+ const status = QUEUE_TABS.find((tab) => tab.id === tabId)?.status
+ const events = useFailedEventPage({ page: pageNumber, perPage: PER_PAGE, status })
+ const counts = useFailedEventCounts()
+ const action = useFailedEventAction()
+
+ // Un solo "ahora" por lectura de la API: el de cuando llegaron los datos. Sin
+ // esto cada celda calcularía el suyo, y un re-render cualquiera movería los
+ // "en 3 minutos" sin que la tabla haya cambiado.
+ const fetchedAt = events.dataUpdatedAt
+ const now = useMemo(() => new Date(fetchedAt), [fetchedAt])
+
+ function run(event: FailedEvent, kind: FailedEventAction) {
+ action.mutate(
+ { id: event.id, action: kind },
+ {
+ onSuccess: () =>
+ notify(kind === 'retry' ? feedback.retried : feedback.discarded, 'success'),
+ onError: (error) =>
+ notify(
+ error.status === NOT_REQUEUEABLE_STATUS ? feedback.notRequeueable : feedback.failed,
+ error.status === NOT_REQUEUEABLE_STATUS ? 'info' : 'error',
+ ),
+ onSettled: () => setDiscarding(undefined),
+ },
+ )
+ }
+
+ const tableColumns: DataTableColumn[] = [
+ {
+ id: 'event',
+ header: columns.event,
+ render: (event) => (
+
+ ),
+ },
+ {
+ id: 'status',
+ header: columns.status,
+ width: 140,
+ render: (event) => (
+
+ ),
+ },
+ {
+ id: 'attempts',
+ header: columns.attempts,
+ align: 'right',
+ width: 110,
+ render: (event) => cells.attempts(event.attempts, event.maxAttempts),
+ },
+ {
+ id: 'nextRetry',
+ header: columns.nextRetry,
+ width: 170,
+ render: (event) => nextRetryCell(event, now),
+ },
+ { id: 'lastError', header: columns.lastError, render: lastErrorCell },
+ {
+ id: 'createdAt',
+ header: columns.createdAt,
+ width: 150,
+ render: (event) => formatRelativeTime(event.createdAt, now) ?? cells.noRetry,
+ },
+ {
+ id: 'actions',
+ header: columns.actions,
+ align: 'right',
+ width: 220,
+ // Botones visibles y no el menú de acciones del DataTable: ese menú no
+ // admite opciones por fila, y acá cada estado acepta cosas distintas.
+ render: (event) => (
+
+ {actionsFor(event.status).includes('discard') ? (
+
+ ) : null}
+ {actionsFor(event.status).includes('retry') ? (
+
+ ) : null}
+
+ ),
+ },
+ ]
+
+ const tabs: DataTableTab[] = QUEUE_TABS.map(({ id }, index) => {
+ const count = counts[index]
+ return {
+ id,
+ label: tabCopy[id],
+ ...(count === undefined ? {} : { count: formatCount(count) }),
+ }
+ })
+
+ if (events.isPending) return
+
+ if (events.isError) {
+ return void events.refetch()} />
+ }
+
+ const { total } = events.data
+ const pageCount = Math.max(1, Math.ceil(total / PER_PAGE))
+ const from = total === 0 ? 0 : (pageNumber - 1) * PER_PAGE + 1
+ const to = Math.min(pageNumber * PER_PAGE, total)
+
+ return (
+
+
+
+ {page.title}
+
+
+ {page.subtitle}
+
+
+
+ event.id}
+ label={page.tableLabel}
+ tabs={tabs}
+ activeTabId={tabId}
+ onTabChange={(next) => {
+ setTabId(next as QueueTabId)
+ setPageNumber(1)
+ }}
+ rowTone={(event) => (event.status === 'dead' ? 'critical' : 'default')}
+ pagination={{
+ page: pageNumber,
+ pageCount,
+ summary: failedEventsCopy.pagination.summary(from, to, total),
+ onPageChange: setPageNumber,
+ }}
+ paginationLabels={{
+ previousLabel: failedEventsCopy.pagination.previous,
+ nextLabel: failedEventsCopy.pagination.next,
+ pageLabel: failedEventsCopy.pagination.page,
+ }}
+ emptyMessage={page.empty}
+ />
+
+ {
+ if (discarding !== undefined) run(discarding, 'discard')
+ }}
+ onClose={() => setDiscarding(undefined)}
+ />
+
+ )
+}
diff --git a/src/features/failed-events/queryKeys.ts b/src/features/failed-events/queryKeys.ts
new file mode 100644
index 0000000..add5400
--- /dev/null
+++ b/src/features/failed-events/queryKeys.ts
@@ -0,0 +1,11 @@
+import type { FailedEventFilters, FailedEventStatus } from './types'
+
+// Factory de query keys de la feature — nunca literales sueltos en los hooks.
+export const failedEventKeys = {
+ all: ['failed-events'] as const,
+ page: (filters: FailedEventFilters) => [...failedEventKeys.all, 'page', filters] as const,
+ // `status ?? 'all'`: sin esto la clave de «todos» dependería de cómo se
+ // serializa un `undefined`.
+ count: (status: FailedEventStatus | undefined) =>
+ [...failedEventKeys.all, 'count', status ?? 'all'] as const,
+}
diff --git a/src/features/failed-events/types.ts b/src/features/failed-events/types.ts
new file mode 100644
index 0000000..76cefda
--- /dev/null
+++ b/src/features/failed-events/types.ts
@@ -0,0 +1,42 @@
+// Tipos de la cola de eventos fallidos (Dead Letter Queue), en camelCase.
+
+/** `FailedEvent::STATUSES`. */
+export type FailedEventStatus = 'pending' | 'processing' | 'succeeded' | 'dead' | 'discarded'
+
+/** Entrante (un webhook que nos llegó) o saliente (un pedido nuestro a un tercero). */
+export type FailedEventDirection = 'inbound' | 'outbound'
+
+/**
+ * Un evento de integración que falló y espera, o esperó, un reintento.
+ * Espejo de `GET /api/v1/failed-events`. Sin `payload` ni cuerpo de la
+ * respuesta: la API no los expone porque pueden traer datos del cliente.
+ */
+export interface FailedEvent {
+ id: number
+ eventType: string
+ direction: FailedEventDirection
+ status: FailedEventStatus
+ attempts: number
+ maxAttempts: number
+ /** `null` cuando ya no se va a reintentar solo (resuelto, agotado, descartado). */
+ nextRetryAt: string | null
+ lastError: string | null
+ lastResponseStatus: number | null
+ createdAt: string
+}
+
+export interface FailedEventFilters {
+ page: number
+ perPage: number
+ /** Sin estado, la API devuelve todos. */
+ status?: FailedEventStatus
+}
+
+export interface FailedEventPage {
+ events: FailedEvent[]
+ page: number
+ perPage: number
+ total: number
+}
+
+export type FailedEventAction = 'retry' | 'discard'
diff --git a/src/features/failed-events/utils/actions.test.ts b/src/features/failed-events/utils/actions.test.ts
new file mode 100644
index 0000000..e5c5806
--- /dev/null
+++ b/src/features/failed-events/utils/actions.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+
+import type { FailedEvent } from '../types'
+
+import { actionsFor, eventLabel } from './actions'
+
+const EVENT: FailedEvent = {
+ id: 1,
+ eventType: 'webhooks.order_ingestion',
+ direction: 'inbound',
+ status: 'dead',
+ attempts: 5,
+ maxAttempts: 5,
+ nextRetryAt: null,
+ lastError: 'timeout',
+ lastResponseStatus: 504,
+ createdAt: '2026-10-01T10:00:00Z',
+}
+
+// Espejo de `RequeueFailedEvent::REQUEUEABLE_STATUSES`: ofrecer lo que la API
+// rechaza con 422 sería prometer algo que no pasa.
+describe('actionsFor', () => {
+ it('offers retrying and discarding an exhausted event', () => {
+ expect(actionsFor('dead')).toEqual(['retry', 'discard'])
+ })
+
+ it('lets a discarded event come back, but not be discarded twice', () => {
+ expect(actionsFor('discarded')).toEqual(['retry'])
+ })
+
+ it('offers nothing for an event that already went through', () => {
+ expect(actionsFor('succeeded')).toEqual([])
+ })
+
+ // La API lo acepta sólo con el claim vencido, y eso el front no lo ve.
+ it('does not offer retrying an event a worker is processing', () => {
+ expect(actionsFor('processing')).not.toContain('retry')
+ })
+})
+
+describe('eventLabel', () => {
+ it('names the known event types in words', () => {
+ expect(eventLabel(EVENT)).toBe('Venta recibida de un canal')
+ })
+
+ it('shows an unknown type as it comes instead of hiding it', () => {
+ expect(eventLabel({ ...EVENT, eventType: 'webhooks.something_new' })).toBe(
+ 'webhooks.something_new',
+ )
+ })
+})
diff --git a/src/features/failed-events/utils/actions.ts b/src/features/failed-events/utils/actions.ts
new file mode 100644
index 0000000..bbc3932
--- /dev/null
+++ b/src/features/failed-events/utils/actions.ts
@@ -0,0 +1,27 @@
+import { EVENT_TYPE_LABELS } from '../content'
+import type { FailedEvent, FailedEventAction, FailedEventStatus } from '../types'
+
+// Qué acciones acepta la API en cada estado. Es la regla de
+// `Webhooks::RequeueFailedEvent::REQUEUEABLE_STATUSES` vista desde la pantalla:
+// ofrecer un botón que la API va a rechazar con 422 es prometer algo que no
+// pasa.
+//
+// `processing` no ofrece reintento: la API lo acepta sólo si el claim del
+// worker venció, y eso el front no lo puede saber. Si quedó colgado, el barrido
+// del cronjob lo rescata solo; si no, el operador lo puede descartar.
+const ACTIONS: Record = {
+ pending: ['retry', 'discard'],
+ processing: ['discard'],
+ dead: ['retry', 'discard'],
+ discarded: ['retry'],
+ succeeded: [],
+}
+
+export function actionsFor(status: FailedEventStatus): readonly FailedEventAction[] {
+ return ACTIONS[status]
+}
+
+/** Nombre legible del tipo de evento, o el tipo tal cual si no se conoce. */
+export function eventLabel(event: FailedEvent): string {
+ return EVENT_TYPE_LABELS[event.eventType] ?? event.eventType
+}