01 · Resumen ejecutivo

La bandeja no es un dashboard: responde "que requiere decision hoy"

FARO-UI-001 es la primera pantalla operativa visible del MVP de FARO Connect. Sin esta pantalla, el motor evaluador puede calcular muy bien pero el usuario no lo percibe. En producto, lo que no se ve, no existe.

Un dashboard muestra indicadores. Una bandeja de tensiones responde, en orden, ocho preguntas concretas: que requiere atencion, por que importa, quien debe actuar, que accion corresponde, que evidencia cierra el tema, que impacto tiene sobre el Score, que esta vencido y que debe escalarse. Si la pantalla no responde esas ocho preguntas, no es una bandeja: es decoracion.

Esta pieza define el contrato completo end-to-end: la vista SQL v_tension_inbox que sirve datos al frontend, el endpoint REST GET /api/v1/tensions, los tipos TypeScript compartidos cliente-servidor, los componentes React principales, el mockup visual estatico, los permisos RLS por rol y los seis casos demo esperados sobre Empresa Demo Cuyo S.A.

El alcance es deliberadamente acotado: MVP 1 = lista de tensiones priorizadas + panel detalle + filtros + Score resumen + acciones visibles + evidencia visible. MVP 2 (fuera de esta pieza) suma crear accion, cambiar estado, cargar evidencia y escalar. MVP 3 suma aprobar evidencia, cerrar accion, cerrar tension y actualizar Score. MVP 4 agrega vistas por responsable, por area y comparacion semanal.

La definicion coordina con FARO-SQL-004 (30 tensiones canonicas), FARO-SQL-005 (300 acciones), FARO-SQL-006 (catalogo de evidencias) y FARO-SQL-002 (RLS PostgreSQL). Sin esos cuatro pilares, la bandeja no tiene de donde leer.

Status enum coordinado con workflow: los 10 estados oficiales son new, in_analysis, in_execution, in_verification, closed, expired, rejected, cancelled, on_hold y reopened. El estado escalated no es estado, es evento (decision D4 del pack): una tension critica puede ser escalada en cualquier momento sin cambiar su estado operativo de fondo.

Frase ejecutiva. La Bandeja de Tensiones es el lugar donde FARO convierte senales en trabajo dirigido. Aca aparece lo que la empresa no deberia ignorar.

Empresa demo visible. Todo el mockup, las metricas y las seis tensiones de ejemplo de la seccion 12 corren sobre el dataset oficial de Empresa Demo Cuyo S.A. (multi-sucursal, multi-pais, dataset semilla aprobado en FARO-SQL-003.1). La pieza nunca exhibe datos de clientes reales.

02 · Tesis de producto + UX priorizacion

Severidad x antiguedad x impacto: ordenar para decidir

La bandeja no se ordena por fecha. Se ordena por severidad x antiguedad x impacto Score. Lo critico vencido aparece primero, lo cerrado al final o fuera de vista. Si la pantalla obliga al usuario a buscar, fallo el producto.

Tesis 01

Bandeja, no dashboard

El usuario no llega a leer indicadores. Llega a decidir. La bandeja responde que requiere accion hoy y oculta el ruido. Un dashboard es museo; una bandeja es mesa de control.

Tesis 02

Prioridad explicita

Cada tension tiene priority_score calculado por motor (0-100). La UI nunca inventa prioridad: la consume. Esto evita que el orden cambie segun quien mira.

Tesis 03

Severidad x antiguedad

Critical vencido > Critical nuevo > High vencido > High nuevo > Medium > Low. Cerradas al final o fuera de vista. El orden no es opinion, es regla SQL.

Tesis 04

Impacto Score visible

Cada tension declara score_impact negativo. El usuario ve cuanto cuesta dejar pasar la tension y cuanto recupera si la cierra. Sin numero, no hay urgencia ejecutiva.

Tesis 05

Responsable y aprobador siempre

Si la tension no tiene responsable asignado, se ofrece accion ACT-OPS-003 automaticamente. Tension sin responsable es tension huerfana y eso no escala.

Tesis 06

Evidencia como cierre

Cerrar tension sin evidencia rompe el modelo. La bandeja muestra evidence_required antes que cualquier boton "cerrar". FARO premia evidencia, no relato.

Orden canonico de la lista

Esta regla vive en SQL (seccion 7) y se replica identica en cualquier vista cliente que pague paginacion. Tocarla en un solo lado rompe consistencia entre web y mobile.

SQL · ORDER BY canonico
ORDER BY
  CASE
    WHEN status = 'expired'  AND severity = 'critical' THEN 1
    WHEN severity = 'critical'                              THEN 2
    WHEN status = 'expired'  AND severity = 'high'     THEN 3
    WHEN severity = 'high'                                  THEN 4
    WHEN severity = 'medium'                                THEN 5
    WHEN severity = 'low'                                   THEN 6
    ELSE 7
  END,
  priority_score DESC,
  detected_at   DESC;

10 estados oficiales de tension

Coordinados con workflow-escalamiento-mvp (WF-001). escalated no entra en el enum: es evento auditable, no estado de fondo.

new

Tension recien detectada por el motor evaluador. Falta tomarla.

in_analysis

El responsable la esta analizando y aun no inicia ejecucion.

in_execution

Ya tiene acciones creadas y en curso operativo.

in_verification

Acciones ejecutadas, falta validar evidencia para cerrar.

closed

Cerrada con evidencia suficiente y aprobador validando.

expired

Vencida sin resolucion. Se mantiene visible y afecta Score.

rejected

Rechazada con justificacion ejecutiva. Salida formal del flujo.

cancelled

Cancelada por cambio de contexto. Diferente a rechazada.

on_hold

Pausa temporal con razon registrada. No cuenta como activa.

reopened

Reabierta tras cierre por evidencia insuficiente o reincidencia.

Decision D4 · escalated es evento, no estado. Una tension critica puede ser escalada a direccion en cualquier momento sin perder su estado operativo de fondo (in_execution, por ejemplo). El escalamiento se registra en tension_events y se muestra como banner ejecutivo en el panel detalle, no como un valor mas del enum.

03 · Layout desktop / mobile

Dos columnas en desktop, single-column con bottom sheet en mobile

El layout desktop divide la pantalla en lista (izquierda, 380-420 px) y panel detalle (derecha, fluido). En mobile se colapsa a single-column con detalle en pantalla completa o bottom sheet. No usamos tablas anchas en mobile: las tablas en mobile son castigo divino.

Desktop · 1280 px+
+-------------------------------------------------------------------------------+ | FARO Connect · Empresa Demo Cuyo S.A. · Semana 22 [Score 66 v-8]| +-------------------------------------------------------------------------------+ | Lo que requiere decision [Score 66] [Crit 2] | | Empresa Demo Cuyo · Mayo 2026 [High 4] [Vencidas 1] | +-------------------------------------------------------------------------------+ | [Buscar...] [Criticas] [Altas] [Nuevas] [Analisis] [Ejecucion] [Vencidas] | +-------------------------------------------------------------------------------+ | LISTA TENSIONES (380px) | PANEL DETALLE (fluido) | | +------------------------------+ | +----------------------------------+ | | | TNS-001 Crit 92 | | | TNS-001 CRITICAL IN_EXECUTION | | | | Crecimiento no rentable | | | Crecimiento no rentable | | | | "vendemos mas, ganamos menos"| | | La empresa crece en volumen... | | | | Maria F. / 3 jun 3/3 ab | | | Score impact: -8.5 Prio 92/100 | | | +------------------------------+ | +----------------------------------+ | | | TNS-004 Crit 88 | | | Pregunta ejecutiva | | | | Venta sin conversion a caja | | | Lectura FARO | | | +------------------------------+ | | Acciones recomendadas (3) | | | | TNS-009 High 78 | | | Responsable + Evidencia | | | | Acciones vencidas | | | [Ver acciones] [Cargar evidencia]| | | +------------------------------+ | +----------------------------------+ | +-------------------------------------------------------------------------------+
Mobile · 360 px
+----------------------------------+ | FARO Connect Score 66 v | +----------------------------------+ | Lo que requiere decision | | 6 activas · 2 criticas · 1 venc | +----------------------------------+ | [Buscar...] | | [Criticas] [Altas] [Nuevas] | +----------------------------------+ | TNS-001 CRITICAL 92 | | Crecimiento no rentable | | "vendemos mas, ganamos menos" | | Maria F. / vence 3 jun | | 3/3 acciones · impacto -8.5 | +----------------------------------+ | TNS-004 CRITICAL 88 | | Venta sin conversion a caja | +----------------------------------+ | TNS-009 HIGH 78 | | Acciones vencidas | +----------------------------------+ [tap] -> bottom sheet detalle -> pregunta ejecutiva -> diagnostico -> acciones -> evidencia -> cambiar estado -> cargar evidencia

Reglas responsive (4 breakpoints). mobile <600px: single-column, detalle bottom sheet. tablet 600-900px: lista arriba, detalle abajo. desktop 900-1280px: split 380px/1fr. large 1280px+: split 420px/1fr con timeline visible y panel mas amplio. Estos breakpoints coinciden con los del resto del pack (pack-nda.css).

04 · 17 componentes principales

El arbol de componentes que sostiene la bandeja

Cada componente tiene una unica responsabilidad. TensionInboxPage es la pagina raiz, los demas son hijos puros. Esto facilita testing, reemplazo y migracion a Storybook sin tocar la pagina contenedor.

TensionInboxPage

Pagina raiz

Orquestador. Hace fetch al endpoint, mantiene estado de filtros y seleccion, decide loading/error/empty. Unico componente con efectos.

TensionSummaryHeader

Header ejecutivo

Empresa, periodo, FARO Score, delta semanal, totales por severidad y vencidas. Lectura ejecutiva de un vistazo, sin scroll.

TensionFilters

Filtros / busqueda

Input de busqueda libre + toggles por severidad + toggles por estado. Sticky en mobile para no perder filtros al scrollear.

TensionList

Lista lateral

Renderiza cards en orden canonico. Scroll independiente del panel detalle. Mantiene seleccion accesible con teclado.

TensionCard

Card de tension

Codigo, severidad, prioridad, titulo, pregunta ejecutiva, responsable, vence, acciones, score. Borde lateral por severidad.

TensionDetailPanel

Panel detalle

Encabezado + diagnostico + KPIs + acciones + evidencia + responsable + score impact + acciones rapidas. Padre de subcomponentes.

TensionDiagnosis

Diagnostico ejecutivo

Renderiza business_question y executive_diagnosis desde catalogo canonico. Nunca hardcodea texto.

TensionKpiDrivers

KPIs que dispararon

Lista de KPIs con valor actual vs referencia, delta, condicion evaluada y estado (ok/warn/critical). Explica el por que.

TensionActionList

Lista de acciones

Renderiza acciones asociadas (creadas + recomendadas). Cada accion expone su evidencia, fecha limite y responsable.

ActionCard

Card de accion

Codigo, titulo, criterio de cierre, evidencia requerida, prioridad, due_date y recuperacion Score esperada. Unidad ejecutable.

EvidenceRequirements

Evidencia requerida

Lista de codigos EVD-* requeridos con nivel de confianza, estado (missing/partial/uploaded) y CTA cargar.

ScoreImpactBlock

Impacto Score

Muestra impacto negativo actual + recuperacion potencial si se ejecutan acciones. Barra de prioridad 0-100.

TensionTimeline

Timeline de eventos

Eventos desde deteccion: cambios de estado, asignaciones, evidencia cargada, escalamientos. Auditoria visible.

EscalationBox

Caja de escalamiento

Solo visible si la tension fue escalada (evento, no estado). Muestra a quien, cuando, por que y respuesta esperada.

EmptyState

Estado vacio

Sin tensiones activas no dice "todo perfecto". Dice "no se detectaron condiciones que requieran intervencion". Prudencia ejecutiva.

LoadingState

Estado de carga

Skeleton estructural, no spinner generico. Replica forma de cards y header para evitar saltos visuales al cargar.

ErrorState

Estado de error

Mensajes diferenciados: API caida, sin permisos, sin company context, catalogo inconsistente, datos incompletos. CTA reintentar.

05 · Data contract API

GET /api/v1/tensions + detalle

El frontend nunca consulta la base directamente. Pasa por dos endpoints REST que aplican RLS, paginan y entregan payload canonico. El contrato es la fuente de verdad entre backend y frontend; ningun lado puede modificarlo unilateralmente.

5.1 Endpoint principal: listado

HTTP · request
GET /api/v1/tensions
  ?status=new,in_analysis,in_execution,expired
  &severity=critical,high
  &area_code=commercial
  &responsible_user_id=12000000-0000-0000-0000-000000000002
  &q=descuento
  &period_start=2026-05-01
  &period_end=2026-05-31
  &sort=priority_desc
  &page=1
  &page_size=50

Headers:
  Authorization: Bearer <jwt>
  X-Faro-Company-Context: <company_id>   // validado contra session, no confiable solo
  Content-Type: application/json

Todos los query params son opcionales. Si no llega company_id, el endpoint usa la sesion. Nunca se confia el company_id de la URL sin validar contra session.company_id.

JSON · response 200 OK
{
  "company": {
    "company_id": "10000000-0000-0000-0000-000000000001",
    "name": "Empresa Demo Cuyo S.A."
  },
  "period": {
    "period_start": "2026-05-01",
    "period_end":   "2026-05-31",
    "label":        "Semana 22 / Mayo 2026"
  },
  "score": {
    "value": 66,
    "previous_value": 74,
    "delta": -8,
    "status": "warning"
  },
  "summary": {
    "total": 6,
    "critical": 2,
    "high": 4,
    "medium": 0,
    "low": 0,
    "expired": 1,
    "without_responsible": 0,
    "without_evidence": 2,
    "total_score_impact": -48.5,
    "potential_recovery_max": 36
  },
  "items": [
    {
      "tension_id": "22000000-0000-0000-0000-000000000001",
      "tension_code": "TNS-001",
      "title": "Crecimiento no rentable",
      "status": "new",
      "severity": "critical",
      "priority_score": 92,
      "confidence_score": 88,
      "area_code": "commercial",
      "module_code": "sales_margin",
      "score_dimension": "commercial_health",
      "score_impact": -8.5,
      "detected_at": "2026-05-30T09:00:00-03:00",
      "due_at":      "2026-06-03T18:00:00-03:00",
      "responsible": {
        "user_id": "12000000-0000-0000-0000-000000000002",
        "full_name": "Maria Fernandez",
        "role": "commercial_manager"
      },
      "actions_summary": {
        "total": 3, "open": 3, "expired": 0,
        "closed": 0, "without_evidence": 3
      },
      "payload": {
        "business_question": "Estamos vendiendo mas pero ganando menos?",
        "executive_diagnosis": "La empresa crece en volumen, pero sacrifica rentabilidad.",
        "recommended_actions": ["ACT-COM-001", "ACT-COM-002", "ACT-COM-003"],
        "evidence_required":    ["EVD-007", "EVD-012"]
      }
    }
  ],
  "pagination": { "page": 1, "page_size": 50, "total": 6, "has_more": false }
}

5.2 Endpoint detalle: GET /api/v1/tensions/:id

El detalle agrega kpi_drivers, actions con evidencia desglosada y timeline completo. El frontend hace fetch lazy al seleccionar la tension; nunca carga todos los detalles en el listado para no inflar payload.

JSON · response detalle (recorte)
{
  "tension": {
    "tension_id": "22000000-0000-0000-0000-000000000001",
    "tension_code": "TNS-001",
    "title": "Crecimiento no rentable",
    "description": "Las ventas crecieron 18%, pero el margen cayo de 28% a 21%.",
    "severity": "critical",
    "priority_score": 92, "confidence_score": 88,
    "status": "new", "score_impact": -8.5,
    "score_dimension": "commercial_health",
    "business_question":  "Estamos vendiendo mas pero ganando menos?",
    "executive_diagnosis": "La empresa crece en volumen, pero sacrifica rentabilidad.",
    "trigger_logic":       "Ventas netas suben + margen bruto cae + descuento promedio sube."
  },
  "kpi_drivers": [
    {
      "kpi_code": "KPI-SAL-001", "name": "Ventas netas",
      "value": 36102650, "reference_value": 30600000,
      "delta_pct": 0.1798, "status": "ok",
      "condition": "delta_pct >= 0.10", "passed": true
    },
    {
      "kpi_code": "KPI-SAL-002", "name": "Margen bruto",
      "value": 0.213, "reference_value": 0.28,
      "delta_pct": -0.2392, "status": "critical",
      "condition": "delta_pct <= -0.10", "passed": true
    }
  ],
  "actions": [
    {
      "action_id": "23000000-0000-0000-0000-000000000001",
      "action_code": "ACT-COM-001",
      "title": "Revisar politica de descuentos",
      "status": "new", "priority": "critical",
      "due_date": "2026-06-06",
      "responsible": { "full_name": "Maria Fernandez" },
      "closure_criteria": "Nueva politica de descuentos aprobada por direccion.",
      "evidence_required": true,
      "evidence_requirements": [
        { "evidence_code": "EVD-007", "name": "Cambio de politica",    "trust_level": "critical", "status": "missing" },
        { "evidence_code": "EVD-012", "name": "Validacion de direccion", "trust_level": "critical", "status": "missing" }
      ],
      "expected_score_recovery_min": 3,
      "expected_score_recovery_max": 8
    }
  ],
  "timeline": [
    { "event_type": "tension_detected", "title": "Tension detectada", "created_at": "2026-05-30T09:00:00-03:00" }
  ]
}

5.3 11 endpoints API minimos del MVP

MetodoEndpointUsoRol minimo
GET/api/v1/tensionsListar tensiones (paginado, filtrable)Responsable+
GET/api/v1/tensions/:idDetalle completo con KPIs, acciones y timelineResponsable+
PATCH/api/v1/tensions/:id/statusCambiar estado operativoResponsable+
PATCH/api/v1/tensions/:id/assignReasignar responsableGerenteG+
POST/api/v1/tensions/:id/escalateEscalar a direccion (evento)GerenteG+
GET/api/v1/tensions/:id/actionsListar acciones asociadasResponsable+
POST/api/v1/actions/:id/evidenceCargar evidencia para accionResponsable+
PATCH/api/v1/actions/:id/statusCambiar estado accionResponsable+
GET/api/v1/catalogs/tensionsCatalogo TNS canonicoCualquiera
GET/api/v1/catalogs/actionsCatalogo ACT canonicoCualquiera
GET/api/v1/catalogs/evidenceCatalogo EVD canonicoCualquiera
06 · Vista SQL v_tension_inbox

Una vista agregada para servir la bandeja sin N+1

La bandeja necesita responsable, conteos de acciones y catalogo enriquecido. Hacerlo desde la app con N+1 queries colapsa el backend cuando la empresa tiene 80 tensiones activas. La vista v_tension_inbox agrega todo en una sola lectura optimizada por company_id + RLS.

SQL · V032__create_v_tension_inbox.sql
CREATE OR REPLACE VIEW faro.v_tension_inbox AS
SELECT
  t.company_id,
  t.tension_id,
  t.tension_code,
  td.name AS catalog_name,
  t.title,
  t.description,
  t.severity,
  t.priority_score,
  t.confidence_score,
  t.status,
  t.detected_at,
  t.due_at,
  t.score_impact,

  td.area_code,
  td.module_code,
  td.business_question,
  td.executive_diagnosis,
  td.trigger_logic,
  td.score_dimension,
  td.default_owner_role,
  td.approver_role,

  t.responsible_user_id,
  u.full_name AS responsible_name,
  u.email     AS responsible_email,

  COUNT(a.action_id) AS actions_total,
  COUNT(a.action_id) FILTER (
    WHERE a.status NOT IN ('closed', 'cancelled', 'rejected')
  ) AS actions_open,
  COUNT(a.action_id) FILTER (
    WHERE a.status NOT IN ('closed', 'cancelled', 'rejected')
      AND a.due_date < CURRENT_DATE
  ) AS actions_expired,
  COUNT(a.action_id) FILTER (
    WHERE a.evidence_required = true
      AND a.status NOT IN ('closed', 'cancelled', 'rejected')
  ) AS actions_with_evidence_required,
  COUNT(a.action_id) FILTER (
    WHERE a.status = 'closed'
  ) AS actions_closed,

  t.payload

FROM faro.tensions t

LEFT JOIN faro.tension_definitions td
  ON td.tension_code = t.tension_code
 AND td.status        = 'active'

LEFT JOIN faro.users u
  ON u.user_id = t.responsible_user_id

LEFT JOIN faro.actions a
  ON a.tension_id = t.tension_id
 AND a.company_id = t.company_id

GROUP BY
  t.company_id, t.tension_id, t.tension_code, td.name,
  t.title, t.description, t.severity, t.priority_score,
  t.confidence_score, t.status, t.detected_at, t.due_at,
  t.score_impact, td.area_code, td.module_code,
  td.business_question, td.executive_diagnosis, td.trigger_logic,
  td.score_dimension, td.default_owner_role, td.approver_role,
  t.responsible_user_id, u.full_name, u.email, t.payload;

-- Indices recomendados sobre tablas base para acelerar la vista
CREATE INDEX IF NOT EXISTS idx_tensions_company_status
  ON faro.tensions (company_id, status) WHERE status NOT IN ('closed', 'rejected');

CREATE INDEX IF NOT EXISTS idx_actions_tension
  ON faro.actions (tension_id, company_id);

CREATE INDEX IF NOT EXISTS idx_tensions_severity_prio
  ON faro.tensions (company_id, severity, priority_score DESC);

-- La vista hereda RLS de tensions/actions/users (FARO-SQL-002 · V018-V022)
-- Ningun usuario puede ver filas de otra company gracias a las policies base

Por que LEFT JOIN en lugar de INNER. Una tension recien creada puede no tener acciones aun y puede tener responsable nulo. INNER JOIN haria desaparecer esas tensiones del listado, que es justo cuando mas necesitan visibilidad ejecutiva. LEFT JOIN + COALESCE en el mapping mantiene la tension visible con conteos en cero.

07 · Query principal API

Una sola SQL parametrizada con orden canonico

El endpoint GET /api/v1/tensions ejecuta esta query unica con 7 parametros. Filtros aplicados con expresiones IS NULL OR para permitir parametros opcionales sin reconstruir el SQL en runtime.

SQL · query bandeja paginada
SELECT
  tension_id,
  tension_code,
  COALESCE(title, catalog_name) AS title,
  severity,
  priority_score,
  confidence_score,
  status,
  detected_at,
  due_at,
  score_impact,
  area_code,
  module_code,
  business_question,
  executive_diagnosis,
  score_dimension,
  responsible_user_id,
  responsible_name,
  responsible_email,
  actions_total,
  actions_open,
  actions_expired,
  actions_with_evidence_required,
  payload

FROM faro.v_tension_inbox

WHERE company_id = $1
  AND ($2::text[] IS NULL OR status   = ANY($2))
  AND ($3::text[] IS NULL OR severity = ANY($3))
  AND ($4::text   IS NULL OR area_code = $4)
  AND (
    $5::text IS NULL
    OR lower(title)        LIKE '%' || lower($5) || '%'
    OR lower(tension_code) LIKE '%' || lower($5) || '%'
  )

ORDER BY
  CASE
    WHEN status = 'expired'  AND severity = 'critical' THEN 1
    WHEN severity = 'critical'                              THEN 2
    WHEN status = 'expired'  AND severity = 'high'     THEN 3
    WHEN severity = 'high'                                  THEN 4
    WHEN severity = 'medium'                                THEN 5
    WHEN severity = 'low'                                   THEN 6
    ELSE 7
  END,
  priority_score DESC,
  detected_at    DESC

LIMIT $6 OFFSET $7;

-- Parametros:
--   $1 company_id      (text/uuid, requerido, viene de sesion)
--   $2 status[]        (text[], opcional, ej: {'new','in_analysis'})
--   $3 severity[]      (text[], opcional, ej: {'critical','high'})
--   $4 area_code       (text, opcional)
--   $5 q               (text, opcional, busqueda libre)
--   $6 page_size       (int, default 50)
--   $7 offset          (int, default 0)
SQL · query de summary (header ejecutivo)
SELECT
  COUNT(*)::int                                                      AS total,
  COUNT(*) FILTER (WHERE severity = 'critical')::int          AS critical,
  COUNT(*) FILTER (WHERE severity = 'high')::int              AS high,
  COUNT(*) FILTER (WHERE severity = 'medium')::int            AS medium,
  COUNT(*) FILTER (WHERE severity = 'low')::int               AS low,
  COUNT(*) FILTER (WHERE status   = 'expired')::int           AS expired,
  COUNT(*) FILTER (WHERE responsible_user_id IS NULL)::int   AS without_responsible,
  COUNT(*) FILTER (WHERE actions_with_evidence_required > 0)::int AS without_evidence,
  SUM(score_impact)                                                  AS total_score_impact
FROM faro.v_tension_inbox
WHERE company_id = $1
  AND status NOT IN ('closed', 'rejected', 'cancelled');

RLS antes del query. Antes de ejecutar estas dos queries, el backend setea contexto con SELECT set_config('app.company_id', $1, true), SELECT set_config('app.user_id', $2, true) y SELECT set_config('app.role_codes', $3, true). Las policies de tensions y actions bloquean cualquier filtro company_id que no coincida. Detalle completo en seguridad-rls-mvp.html.

08 · Tipos TypeScript completos

Contrato compartido cliente-servidor

Estos tipos viven en lib/faro/tensions.types.ts y son consumidos por la API route, los componentes React y los tests. Cualquier cambio en el contrato JSON debe arrancar aca; si no compila, no llega a produccion.

TS · lib/faro/tensions.types.ts
// ============================================================
// FARO Connect · Tipos compartidos Bandeja de Tensiones
// Coordinado con catalogo-tensiones-mvp.html (TNS-001..TNS-030)
// Coordinado con workflow-escalamiento-mvp.html (10 estados)
// ============================================================

export type TensionSeverity =
  | "low"
  | "medium"
  | "high"
  | "critical";

export type TensionStatus =
  | "new"
  | "in_analysis"
  | "in_execution"
  | "in_verification"
  | "closed"
  | "expired"
  | "rejected"
  | "cancelled"
  | "on_hold"
  | "reopened";

// "escalated" es evento (no estado) · decision D4 del pack
export type TensionEventType =
  | "tension_detected"
  | "status_changed"
  | "assigned"
  | "escalated"
  | "evidence_uploaded"
  | "evidence_approved"
  | "action_created"
  | "action_closed"
  | "comment_added";

export type FaroScoreSummary = {
  value:          number;
  previous_value: number | null;
  delta:          number | null;
  status:         "healthy" | "warning" | "critical" | "unknown";
};

export type TensionResponsible = {
  user_id:   string | null;
  full_name: string | null;
  role?:     string | null;
  email?:    string | null;
};

export type TensionActionsSummary = {
  total:            number;
  open:             number;
  expired:          number;
  closed:           number;
  without_evidence: number;
};

export type TensionInboxItem = {
  tension_id:       string;
  tension_code:     string;
  title:            string;
  status:           TensionStatus;
  severity:         TensionSeverity;
  priority_score:   number;
  confidence_score: number | null;
  area_code:        string | null;
  module_code:      string | null;
  score_dimension:  string | null;
  score_impact:     number | null;
  detected_at:      string;  // ISO 8601
  due_at:           string | null;
  responsible:      TensionResponsible;
  actions_summary:  TensionActionsSummary;
  payload: {
    business_question?:   string;
    executive_diagnosis?: string;
    recommended_actions?: string[];
    evidence_required?:   string[];
  };
};

export type TensionFilters = {
  status?:              TensionStatus[];
  severity?:            TensionSeverity[];
  area_code?:           string;
  responsible_user_id?: string;
  q?:                   string;
  period_start?:        string;
  period_end?:          string;
  page?:                number;
  page_size?:           number;
};

export type TensionInboxResponse = {
  company: {
    company_id: string;
    name:       string;
  };
  period: {
    period_start: string;
    period_end:   string;
    label:        string;
  };
  score: FaroScoreSummary;
  summary: {
    total:                 number;
    critical:              number;
    high:                  number;
    medium:                number;
    low:                   number;
    expired:               number;
    without_responsible:   number;
    without_evidence:      number;
    total_score_impact:    number;
    potential_recovery_max: number;
  };
  items:      TensionInboxItem[];
  pagination: {
    page:      number;
    page_size: number;
    total:     number;
    has_more:  boolean;
  };
};

export type TensionKpiDriver = {
  kpi_code:        string;
  name:            string;
  value:           number;
  reference_value: number;
  delta_pct:       number;
  status:          "ok" | "warning" | "critical";
  condition:       string;
  passed:          boolean;
};

export type TensionAction = {
  action_id:          string;
  action_code:        string;
  title:              string;
  status:             string;
  priority:           string;
  due_date:           string;
  responsible:        { full_name: string | null };
  closure_criteria:   string;
  evidence_required:  boolean;
  evidence_requirements: {
    evidence_code: string;
    name:          string;
    trust_level:   "low" | "medium" | "high" | "critical";
    status:        "missing" | "partial" | "uploaded" | "approved";
  }[];
  expected_score_recovery_min: number;
  expected_score_recovery_max: number;
};

export type TensionTimelineEvent = {
  event_type: TensionEventType;
  title:      string;
  created_at: string;
  actor?:     { user_id: string; full_name: string };
  payload?:   Record<string, unknown>;
};

export type TensionDetailResponse = {
  tension:     TensionInboxItem & {
    description:         string;
    business_question:   string;
    executive_diagnosis: string;
    trigger_logic:       string;
  };
  kpi_drivers: TensionKpiDriver[];
  actions:     TensionAction[];
  timeline:    TensionTimelineEvent[];
};
09 · Componentes React de muestra

Page + header + filtros + lista + card + panel detalle

Codigo conceptual de los 6 componentes mas relevantes. El stack recomendado es Next.js 14+ (App Router) + React 18 + TypeScript + Tailwind. Los estilos del mockup quedan en pack-nda.css; los componentes React usan Tailwind utilitario para no acoplarse al pack de documentacion.

9.1 Page principal

TSX · app/faro/tensions/page.tsx
import { TensionInboxPage } from "@/components/tensions/TensionInboxPage";

export default function Page() {
  return <TensionInboxPage />;
}

9.2 Orquestador con estado

TSX · components/tensions/TensionInboxPage.tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import { getTensionInbox } from "@/lib/faro/tensions.api";
import type { TensionInboxItem, TensionInboxResponse } from "@/lib/faro/tensions.types";
import { TensionSummaryHeader } from "./TensionSummaryHeader";
import { TensionFilters }       from "./TensionFilters";
import { TensionList }          from "./TensionList";
import { TensionDetailPanel }   from "./TensionDetailPanel";
import { TensionEmptyState }    from "./TensionEmptyState";

export function TensionInboxPage() {
  const [data, setData] = useState<TensionInboxResponse | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [severity, setSeverity] = useState<string[]>(["critical", "high"]);
  const [status, setStatus]     = useState<string[]>([
    "new", "in_analysis", "in_execution", "expired"
  ]);
  const [query, setQuery]       = useState("");
  const [loading, setLoading]   = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function load() {
      setLoading(true);
      try {
        const result = await getTensionInbox({
          severity, status, q: query, page: 1, page_size: 50
        });
        if (!cancelled) {
          setData(result);
          setSelectedId((current) => current ?? result.items[0]?.tension_id ?? null);
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    load();
    return () => { cancelled = true; };
  }, [severity, status, query]);

  const selected = useMemo<TensionInboxItem | null>(() => {
    if (!data || !selectedId) return null;
    return data.items.find((it) => it.tension_id === selectedId) ?? data.items[0] ?? null;
  }, [data, selectedId]);

  if (loading && !data) return <LoadingState />;
  if (!data)             return <ErrorState />;

  return (
    <main className="min-h-screen bg-[#F3F8FF] px-4 py-5 md:px-6 md:py-6 text-[#071A44]">
      <div className="mx-auto flex max-w-7xl flex-col gap-5">
        <TensionSummaryHeader data={data} />

        <TensionFilters
          severity={severity}
          status={status}
          query={query}
          onSeverityChange={setSeverity}
          onStatusChange={setStatus}
          onQueryChange={setQuery}
        />

        {data.items.length === 0 ? (
          <TensionEmptyState />
        ) : (
          <section className="grid gap-5 lg:grid-cols-[420px_1fr]">
            <TensionList
              items={data.items}
              selectedId={selected?.tension_id ?? null}
              onSelect={setSelectedId}
            />
            <TensionDetailPanel tension={selected} />
          </section>
        )}
      </div>
    </main>
  );
}

9.3 Header ejecutivo

TSX · components/tensions/TensionSummaryHeader.tsx
import type { TensionInboxResponse } from "@/lib/faro/tensions.types";

export function TensionSummaryHeader({ data }: { data: TensionInboxResponse }) {
  const delta      = data.score.delta ?? 0;
  const deltaLabel = delta > 0 ? `+${delta}` : `${delta}`;

  return (
    <section className="rounded-3xl border border-black/10 bg-white p-5 shadow-sm md:p-6">
      <div className="flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
        <div>
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#1F5BFF]">
            FARO Connect · Bandeja de Tensiones
          </p>
          <h1 className="mt-2 text-2xl font-semibold tracking-[-0.03em] md:text-4xl">
            Lo que requiere decision
          </h1>
          <p className="mt-2 max-w-2xl text-sm leading-6 text-[#4B587C]">
            {data.company.name} · {data.period.label}.
          </p>
        </div>

        <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
          <Metric label="FARO Score" value={data.score.value} sub={deltaLabel} />
          <Metric label="Criticas"  value={data.summary.critical} sub="prioridad maxima" />
          <Metric label="Altas"     value={data.summary.high}     sub="requieren gestion" />
          <Metric label="Vencidas"  value={data.summary.expired}  sub="fuera de SLA" />
        </div>
      </div>
    </section>
  );
}

function Metric({ label, value, sub }: { label: string; value: number; sub: string }) {
  return (
    <div className="rounded-2xl border border-black/10 bg-[#F7FAFF] px-4 py-3">
      <div className="text-xs font-medium text-[#4B587C]">{label}</div>
      <div className="mt-1 text-2xl font-semibold">{value}</div>
      <div className="mt-1 text-[11px] text-[#4B587C]">{sub}</div>
    </div>
  );
}

9.4 Filtros (chips + busqueda)

TSX · components/tensions/TensionFilters.tsx
type Props = {
  severity:          string[];
  status:            string[];
  query:             string;
  onSeverityChange:  (value: string[]) => void;
  onStatusChange:    (value: string[]) => void;
  onQueryChange:     (value: string)   => void;
};

const severityOptions = [
  { value: "critical", label: "Criticas" },
  { value: "high",     label: "Altas" },
  { value: "medium",   label: "Medias" },
  { value: "low",      label: "Bajas" }
];

const statusOptions = [
  { value: "new",             label: "Nuevas" },
  { value: "in_analysis",     label: "Analisis" },
  { value: "in_execution",    label: "Ejecucion" },
  { value: "in_verification", label: "Verificacion" },
  { value: "expired",         label: "Vencidas" },
  { value: "on_hold",         label: "En pausa" }
];

export function TensionFilters(props: Props) {
  return (
    <section className="rounded-3xl border border-black/10 bg-white p-4 shadow-sm">
      <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
        <input
          value={props.query}
          onChange={(e) => props.onQueryChange(e.target.value)}
          placeholder="Buscar por tension, codigo, responsable o area..."
          className="min-h-11 flex-1 rounded-2xl border border-black/10 bg-white px-4 text-sm outline-none focus:border-[#1F5BFF]"
        />

        <div className="flex flex-wrap gap-2">
          {severityOptions.map((o) => (
            <Toggle key={o.value} label={o.label}
              active={props.severity.includes(o.value)}
              onClick={() => toggle(props.severity, o.value, props.onSeverityChange)} />
          ))}
        </div>

        <div className="flex flex-wrap gap-2">
          {statusOptions.map((o) => (
            <Toggle key={o.value} label={o.label}
              active={props.status.includes(o.value)}
              onClick={() => toggle(props.status, o.value, props.onStatusChange)} />
          ))}
        </div>
      </div>
    </section>
  );
}

function Toggle({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
  return (
    <button type="button" onClick={onClick} className={[
      "rounded-full border px-3 py-2 text-xs font-semibold transition",
      active
        ? "border-[#071A44] bg-[#071A44] text-white"
        : "border-black/10 bg-white text-[#4B587C] hover:border-[#1F5BFF]/30"
    ].join(" ")}>
      {label}
    </button>
  );
}

function toggle(current: string[], value: string, setter: (v: string[]) => void) {
  setter(current.includes(value) ? current.filter((x) => x !== value) : [...current, value]);
}

9.5 Lista + Card

TSX · components/tensions/TensionList.tsx + TensionCard.tsx
import type { TensionInboxItem } from "@/lib/faro/tensions.types";
import { severityLabel, severityTone } from "@/lib/faro/severity";
import { formatDateShort } from "@/lib/faro/formatters";

export function TensionList({ items, selectedId, onSelect }: {
  items: TensionInboxItem[]; selectedId: string | null;
  onSelect: (id: string) => void;
}) {
  return (
    <aside className="flex max-h-[calc(100vh-260px)] flex-col gap-3 overflow-auto rounded-3xl border border-black/10 bg-white p-3">
      {items.map((item) => (
        <TensionCard key={item.tension_id} item={item}
          selected={item.tension_id === selectedId}
          onClick={() => onSelect(item.tension_id)} />
      ))}
    </aside>
  );
}

export function TensionCard({ item, selected, onClick }: {
  item: TensionInboxItem; selected: boolean; onClick: () => void;
}) {
  const tone = severityTone(item.severity);
  return (
    <button type="button" onClick={onClick} className={[
      "w-full rounded-2xl border p-4 text-left transition",
      selected
        ? "border-[#1F5BFF] bg-white shadow-md"
        : "border-black/10 bg-white hover:border-[#1F5BFF]/30"
    ].join(" ")}>
      <div className="flex items-start justify-between gap-3">
        <div>
          <div className="flex items-center gap-2">
            <span className="text-xs font-bold text-[#4B587C]">{item.tension_code}</span>
            <span className={`rounded-full px-2 py-1 text-[10px] font-bold uppercase ${tone.badge}`}>
              {severityLabel(item.severity)}
            </span>
          </div>
          <h3 className="mt-2 text-sm font-semibold leading-5">{item.title}</h3>
        </div>
        <div className="text-right">
          <div className="text-xl font-semibold">{item.priority_score}</div>
          <div className="text-[10px] uppercase tracking-[0.16em] text-[#4B587C]">prioridad</div>
        </div>
      </div>
      <p className="mt-3 line-clamp-2 text-xs text-[#4B587C]">
        {item.payload.business_question ?? item.payload.executive_diagnosis}
      </p>
      <div className="mt-4 grid grid-cols-2 gap-2 text-[11px] text-[#4B587C]">
        <Info label="Responsable" value={item.responsible.full_name ?? "Sin asignar"} />
        <Info label="Vence" value={item.due_at ? formatDateShort(item.due_at) : "Sin fecha"} />
        <Info label="Acciones" value={`${item.actions_summary.open}/${item.actions_summary.total} abiertas`} />
        <Info label="Score"    value={item.score_impact ? `${item.score_impact}` : "-"} />
      </div>
    </button>
  );
}

function Info({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-xl bg-[#F7FAFF] px-3 py-2">
      <div className="text-[10px] uppercase tracking-[0.14em] text-[#4B587C]">{label}</div>
      <div className="mt-1 truncate font-medium text-[#071A44]">{value}</div>
    </div>
  );
}

9.6 Panel detalle (resumen)

TSX · components/tensions/TensionDetailPanel.tsx (extracto)
export function TensionDetailPanel({ tension }: { tension: TensionInboxItem | null }) {
  if (!tension) {
    return (
      <section className="rounded-3xl border border-black/10 bg-white p-6">
        Seleccione una tension para ver el detalle.
      </section>
    );
  }

  const tone = severityTone(tension.severity);

  return (
    <section className="min-h-[620px] rounded-3xl border border-black/10 bg-white p-5 md:p-6">
      <header className="flex flex-col justify-between gap-4 border-b border-black/10 pb-5 md:flex-row">
        <div>
          <div className="flex flex-wrap items-center gap-2">
            <span className="text-xs font-bold text-[#4B587C]">{tension.tension_code}</span>
            <span className={`rounded-full px-2 py-1 text-[10px] font-bold uppercase ${tone.badge}`}>
              {severityLabel(tension.severity)}
            </span>
            <span className="rounded-full border border-black/10 bg-[#F7FAFF] px-2 py-1 text-[10px] font-bold uppercase">
              {tension.status}
            </span>
          </div>
          <h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em]">{tension.title}</h2>
          <p className="mt-2 max-w-3xl text-sm text-[#4B587C]">
            {tension.payload.executive_diagnosis ?? tension.payload.business_question}
          </p>
        </div>
        <ScoreImpactBlock impact={tension.score_impact} priority={tension.priority_score} />
      </header>

      <div className="grid gap-5 py-5 xl:grid-cols-[1.2fr_.8fr]">
        <div className="space-y-5">
          <Block title="Pregunta ejecutiva">{tension.payload.business_question}</Block>
          <Block title="Lectura FARO">{tension.payload.executive_diagnosis}</Block>
          <Block title="Acciones recomendadas">
            <div className="flex flex-wrap gap-2">
              {(tension.payload.recommended_actions ?? []).map((code) => (
                <span key={code} className="rounded-full border border-black/10 bg-[#F7FAFF] px-3 py-2 text-xs font-semibold">
                  {code}
                </span>
              ))}
            </div>
          </Block>
        </div>

        <div className="space-y-5">
          <Block title="Responsable">
            <p className="font-semibold">{tension.responsible.full_name ?? "Sin asignar"}</p>
            <p className="mt-1 text-xs text-[#4B587C]">{tension.responsible.email ?? "Sin email"}</p>
          </Block>
          <Block title="Evidencia requerida">
            <div className="flex flex-wrap gap-2">
              {(tension.payload.evidence_required ?? []).map((code) => (
                <span key={code} className="rounded-full bg-[#071A44] px-3 py-2 text-xs font-semibold text-white">
                  {code}
                </span>
              ))}
            </div>
          </Block>
        </div>
      </div>

      <div className="flex flex-wrap gap-3 border-t border-black/10 pt-5">
        <button className="rounded-full bg-[#071A44] px-5 py-3 text-sm font-semibold text-white">
          Ver acciones
        </button>
        <button className="rounded-full border border-black/10 bg-white px-5 py-3 text-sm font-semibold">
          Cargar evidencia
        </button>
        <button className="rounded-full border border-[#22C8FF]/40 bg-[#22C8FF]/10 px-5 py-3 text-sm font-semibold text-[#102E7A]">
          Escalar a direccion
        </button>
      </div>
    </section>
  );
}
10 · Mockup visual estatico

Como se ve la bandeja con datos reales de Empresa Demo Cuyo

Mockup HTML/CSS estatico de la pantalla completa. Lista lateral con 5 tensiones priorizadas + panel detalle con TNS-001 seleccionada. Replica visual del producto, no captura de pantalla. Datos del seed oficial de Empresa Demo Cuyo S.A.

Sobre el mockup. Esta pieza es una replica visual estatica con HTML/CSS inline para mantenerse dentro del pack NDA. La implementacion real usa los componentes React de la seccion 9 con Tailwind. La paridad visual entre este mockup y el producto final es responsabilidad del equipo frontend.

11 · Permisos por rol + RLS aplicado

Que ve cada rol y como lo protege Postgres

La UI puede ofrecer botones, pero la seguridad real vive en backend + RLS. Ningun rol puede ver tensiones de otra empresa, ni siquiera por error de la UI. Estos permisos se replican identicos en el endpoint y en las policies de FARO-SQL-002.

AccionRoles permitidosNotas
Ver bandeja directorgeneral_managerarea_managerresponsable Cualquiera con rol activo en la empresa. RLS filtra por company_id.
Ver tension propia responsable Solo si responsible_user_id = session.user_id o si delegado.
Cambiar estado responsablegeneral_manager Responsable solo puede mover hacia adelante; gerente puede revertir.
Cargar evidencia responsabledata_ownerarea_manager La evidencia siempre queda en estado uploaded hasta aprobacion.
Aprobar evidencia area_managergeneral_managerdirector Sin aprobacion, la accion no puede cerrar definitivamente.
Cerrar tension general_managerdirector Requiere evidencia minima aprobada + criterio de cierre cumplido.
Escalar tension general_managerdirector Registra evento auditable + notifica direccion. No cambia estado.
Rechazar tension general_managerdirector Requiere justificacion en texto libre + queda en auditoria.
Cambiar responsable general_managerdirector Notifica al saliente y al entrante; preserva timeline.

11.1 RLS aplicado antes de cada query

El backend nunca confia el company_id del request. Lo lee de la sesion validada, lo setea como contexto y deja que las policies de Postgres bloqueen cualquier acceso transversal. Esto sigue el patron de FARO-SQL-002.

TS · app/api/v1/tensions/route.ts (extracto seguro)
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/server/db";
import { getSessionContext } from "@/lib/server/session";

export async function GET(request: NextRequest) {
  const session = await getSessionContext();
  if (!session?.companyId || !session?.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const search   = request.nextUrl.searchParams;
  const severity = search.get("severity")?.split(",") ?? null;
  const status   = search.get("status")?.split(",")   ?? null;
  const areaCode = search.get("area_code");
  const q        = search.get("q");
  const page     = Number(search.get("page") ?? 1);
  const pageSize = Number(search.get("page_size") ?? 50);
  const offset   = (page - 1) * pageSize;

  const client = await db.connect();
  try {
    await client.query("BEGIN");

    // RLS context · viene de sesion, NUNCA del request
    await client.query(`SELECT set_config('app.company_id', $1, true)`, [session.companyId]);
    await client.query(`SELECT set_config('app.user_id',    $1, true)`, [session.userId]);
    await client.query(`SELECT set_config('app.role_codes', $1, true)`, [session.roleCodes.join(",")]);

    const result = await client.query(
      /* query principal seccion 7 */,
      [session.companyId, status, severity, areaCode, q, pageSize, offset]
    );

    const summary = await client.query(
      /* query summary seccion 7 */,
      [session.companyId]
    );

    await client.query("COMMIT");
    return NextResponse.json({
      company:    { company_id: session.companyId, name: session.companyName },
      period:     { period_start: "2026-05-01", period_end: "2026-05-31", label: "Mayo 2026" },
      score:      { value: 66, previous_value: 74, delta: -8, status: "warning" },
      summary:    summary.rows[0],
      items:      result.rows.map(mapTensionRow),
      pagination: { page, page_size: pageSize, total: summary.rows[0].total, has_more: false }
    });
  } catch (error) {
    await client.query("ROLLBACK");
    return NextResponse.json({ error: "Could not load tensions" }, { status: 500 });
  } finally {
    client.release();
  }
}

Criterio de rechazo de seguridad. Esta pieza se rechaza si: la UI manda company_id libre sin validar contra sesion · el frontend cachea datos de una empresa y los muestra a otra al cambiar contexto · el endpoint expone tension_id ajeno por ID predecible · la response incluye responsible_email de roles que no deberian verlo. Cualquiera de estos casos es bloqueante de piloto.

12 · 6 tensiones demo + criterios + metricas UX

Lo que debe aparecer en la primera demo de Empresa Demo Cuyo

La pantalla de la demo inaugural debe mostrar estas seis tensiones (dos criticas, cuatro altas). Cada una representa una clase de problema distinta: rentabilidad, caja, stock, ejecucion, gobierno de evidencia. Sirven para narrar el rango completo del motor en menos de tres minutos.

TNS-001 Critical

Crecimiento no rentable

Ventas 36.1 M (+18%) pero margen bruto pasa de 28% a 21% y descuento promedio sube 14.5%. La empresa crece comprando margen.

commercial_manager · Maria F. -8.5 score
3 acciones · 0 evidencia Vence 3 jun
TNS-004 Critical

Venta sin conversion a caja

Facturacion sube, pero DSO crece de 38 a 54 dias. El crecimiento esta financiando al cliente y la caja proyectada se compromete.

finance_manager · Diego O. -9.0 score
3 acciones · 1 evidencia Vence 5 jun
TNS-006 High

Stock critico en alta rotacion

Cobertura promedio < 5 dias en 12 SKUs A. Riesgo de quiebre con perdida estimada de venta semanal de 1.2 M.

stock_manager · Federico S. -6.5 score
2 acciones · 0 evidencia Vence 7 jun
TNS-007 High

Stock inmovilizado

18% del stock valorizado en SKUs sin movimiento >120 dias. Caja inmovilizada estimada: 4.8 M. Genera pesadez financiera silenciosa.

stock_manager · Federico S. -5.0 score
2 acciones · 0 evidencia Vence 10 jun
TNS-009 High

Acciones vencidas sin corregir

4 de 5 acciones de decisiones previas vencidas sin reaccion. Sin responsable asignado. Tension de gobierno ejecutivo.

general_manager · Sin asignar -7.0 score
5 acciones · 4 vencidas Vencida 29 may
TNS-010 High

Acciones sin evidencia cargada

3 acciones marcadas cerradas por responsable sin EVD-* cargada. Riesgo de cierre ficticio y reincidencia.

area_manager · Maria F. -5.5 score
3 acciones · 0 evidencia Vence 8 jun

12.1 Criterios de aceptacion funcional

FUNC 01

Lista priorizada

Muestra tensiones activas ordenadas por severidad x antiguedad x prioridad.

FUNC 02

Detalle al seleccionar

Click en card actualiza panel derecho con diagnostico, acciones, evidencia.

FUNC 03

Severidad visible

Cada card declara severidad con borde lateral + badge sobrio (no semaforo infantil).

FUNC 04

Responsable siempre

Si falta responsable, se muestra alerta y CTA asignar.

FUNC 05

Impacto Score

Cada tension expone score_impact negativo y recuperacion potencial.

FUNC 06

Pregunta ejecutiva

Cada tension expone su business_question desde catalogo canonico.

FUNC 07

Acciones recomendadas

Codigos ACT-* visibles, clickeables a detalle de accion.

FUNC 08

Evidencia requerida

Codigos EVD-* visibles con estado missing/uploaded.

FUNC 09

Filtros severidad/estado

Toggle chips funcional sin recarga, persistencia en URL.

FUNC 10

Busqueda libre

Input que busca en titulo y codigo, debounce 300ms.

FUNC 11

Empty/Loading/Error

Estados diferenciados con copy ejecutivo, sin "todo perfecto".

FUNC 12

Responsive 4 breakpoints

Mobile, tablet, desktop, large. Sin tablas anchas en mobile.

12.2 Metricas UX que la bandeja debe sostener

Metrica UXObjetivoComo se mide
Time-to-first-decision< 90 segundos desde loginTiempo entre cargar bandeja y primer cambio de estado de tension.
Tensiones revisadas / sesion>= 80% del total activoCards con click vs total renderizado.
Acciones creadas / tension critica>= 1 por sesionPOST /api/v1/actions por sesion / criticas vistas.
Evidencia cargada / accion cerrada100% con evidenciaAcciones en closed con al menos 1 EVD-* aprobada.
Tasa de rebote del panel detalle< 15%Selecciones sin scroll ni interaccion adicional.
Tiempo de carga inicial (P95)< 1.2 s con 50 tensionesWeb vitals + tiempo de query SQL.
Tensiones sin responsable activas0 sostenidoConteo de responsible_user_id IS NULL + alerta ejecutiva.
Tasa de escalamiento sano5-15% del totalMas alta = ejecutivo desbordado; mas baja = no se usa el mecanismo.

Criterio de rechazo UX. La bandeja se rechaza si: parece dashboard generico · no muestra acciones · no muestra evidencia · no muestra responsable · no distingue critical/high con claridad sobria · no es usable en mobile real · usa datos hardcodeados como fuente final · no respeta company context · muestra codigos sin nombres legibles · no permite entender por que se disparo la tension. Cualquiera de estos es bloqueante.

→ Cross-references

Donde se conecta la bandeja con el resto del pack

La bandeja consume catalogos canonicos, vive sobre seguridad RLS, dispara workflow de escalamiento y se complementa con dashboards por responsable y vistas de accion individual. Estas son las piezas con las que coordina, en orden de cercania.

CONSUME
catalogo-tensiones-mvp.html

30 tensiones canonicas TNS-001..TNS-030. Fuente de nombre, area, severidad base, pregunta ejecutiva y dimension Score.

CONSUME
catalogo-acciones-mvp.html

Catalogo ACT canonico. Los recommended_actions de cada tension resuelven aqui.

CONSUME
catalogo-evidencias-mvp.html

Catalogo EVD canonico con niveles de confianza y estados missing/partial/uploaded/approved.

PROTEGE
seguridad-rls-mvp.html

Capa RLS PostgreSQL que aisla por company_id y rol. La bandeja confia en estas policies para no exponer datos cruzados.

IMPLEMENTA
modelo-sql.html

DDL completo del sistema. Define tablas tensions, actions, evidence, users que la vista v_tension_inbox agrega.

DISPARA PENDIENTE WF-001
workflow-escalamiento-mvp.html

Definicion oficial de los 10 estados y del evento escalated. Spec hermana de esta pieza, aun en construccion.

SUCEDE PENDIENTE UI-002
ui-dashboard-responsable.html

Dashboard personal de cada responsable: mis tensiones, mis acciones, vencimientos, evidencias pendientes. Siguiente pantalla del MVP.

SUCEDE PENDIENTE UI-003
ui-workflow-accion.html

Pantalla individual de una accion: criterio de cierre, evidencia, cambio de estado, recuperacion Score esperada.

ALIMENTA PENDIENTE ENG-003
motor-evaluador-mvp.html

Motor que dispara las tensiones leidas por la bandeja. Define priority_score, confidence_score y score_impact.

USA
diagnostico-empresarial.html

Diagnostico ejecutivo trimestral que toma como insumo las tensiones cerradas y abiertas que vive la bandeja.

EXPORTA
reportes-ejecutivos.html

Reporte semanal automatico que resume la bandeja para direccion. Misma fuente, formato comprimido.

DEPENDE
contrato-datos-mvp.html

Contrato de datos minimo del MVP. Sin estas fuentes pobladas, la bandeja se rinde con datos insuficientes.