Bandeja de Tensiones
Vista de la empresa. Lectura ejecutiva para dirección, gerencia general y validadores.
GET /api/v1/tensionsv_tension_boardVista diaria por responsable. Resuelve la pregunta operativa que la Bandeja de Tensiones no responde: "¿qué tengo que hacer yo hoy?". Empuja ejecución, evidencia y recuperación de Score sin que nadie tenga que perseguir manualmente.
FARO-UI-002 es la pantalla donde FARO deja de ser "inteligencia ejecutiva" y se convierte en "disciplina operativa". El responsable entra, ve sus acciones del día y trabaja. No mira gráficos: ejecuta.
La Bandeja de Tensiones (FARO-UI-001) responde la pregunta de la empresa: ¿qué tensiones tenemos abiertas y cómo están priorizadas? El Dashboard Responsable responde la pregunta del individuo: ¿qué tengo que resolver hoy, qué se me vence, qué evidencia me falta, qué impacto Score tiene mi acción?
Sin esta vista, FARO puede detectar mucho y ejecutar poco. El sistema se llenaría de tensiones brillantes sin nadie persiguiendo el cierre. Perseguir manualmente tareas críticas es un deporte caro: por eso esta UI existe como tope obligatorio del pipeline canónico.
Cada responsable de Empresa Demo Cuyo S.A. entra cada mañana y ve, en este orden:
Vencida y motivo de demora si lo hay.critical, aunque tenga 5 días para resolver.high dentro de la semana.Esta priorización no es decorativa: es la cláusula ORDER BY de la query principal de faro.v_responsible_action_dashboard (ver sección 8). Si la UI muestra otro orden, está rota y se rechaza.
El dashboard cumple tres reglas duras del MVP:
evidence_missing_count > 0 en acciones con evidence_required = true. Sin respaldo no hay cierre./api/v1/me/actions usa session.user_id; no acepta responsible_user_id libre por query param. Gerentes y directores usan endpoints distintos con permisos superiores.new, in_progress, blocked, waiting_evidence, in_review, approved, closed, expired, cancelled, rejected) están coordinados con WF-001 · Workflow de Acción. El evento escalated es un evento del timeline (D4), no un estado terminal.El UI vive en app/faro/my-actions/page.tsx, consume faro.v_responsible_action_dashboard y se alimenta con datos de Empresa Demo Cuyo S.A. en el seed canónico (FARO-SQL-003.1). Sin este módulo el sistema detecta problemas pero depende de que alguien los persiga manualmente, y eso ya sabemos cómo termina.
UI-001 ordena el mapa. UI-002 mueve la ejecución. Mezclar ambas en una sola pantalla termina en un dashboard genérico que sirve para nada porque pretende servir para todo.
Vista de la empresa. Lectura ejecutiva para dirección, gerencia general y validadores.
GET /api/v1/tensionsv_tension_boardVista individual ejecutiva. Cada responsable ve solo lo suyo, priorizado por urgencia y criticidad.
GET /api/v1/me/actionsv_responsible_action_dashboardRegla canónica. La Bandeja muestra tensiones; el Dashboard muestra acciones. Una tensión puede tener N acciones, cada una con su responsable. La Bandeja se filtra por empresa, área y severidad. El Dashboard se filtra implícitamente por session.user_id y explícitamente por estado, prioridad, vencimiento y búsqueda.
El Dashboard sirve a 7 perfiles operativos en Empresa Demo Cuyo S.A. Cada uno ve un subset distinto del catálogo de acciones canónicas (FARO-SQL-005) según el rol asignado en action_definitions.default_owner_role y el responsable concreto en actions.responsible_user_id.
Cobranza, mora, caja proyectada, riesgo crediticio, dependencia de pocos clientes en cartera. Filtra area_code = finance.
ACT-FIN-001 a ACT-FIN-008TNS-004, TNS-005, TNS-017, TNS-019Visión transversal: acciones cruzadas entre áreas, decisiones operativas pendientes, escalamientos recibidos de gerencias funcionales.
ACT-OPS-001, ACT-OPS-002, ACT-DIR-001TNS-009, TNS-010Descuentos, vendedores erosionando margen, política comercial, sucursales fuera de plan, concentración de clientes. Filtra area_code = commercial.
ACT-COM-001 a ACT-COM-006TNS-001, TNS-002, TNS-003, TNS-011, TNS-013Acciones de capacitación derivadas de tensiones operativas, asignación de responsables faltantes, soporte a gerencias en cambios estructurales.
TNS-029 (responsable no asignado)Quiebres con venta perdida, reposición, inmovilizado, alta rotación crítica. Filtra area_code = stock.
ACT-STK-001 a ACT-STK-005TNS-006, TNS-007, TNS-008, TNS-021Fuentes atrasadas, KPIs de baja confianza, integraciones rotas, calidad de datos. Las únicas tensiones que un Data Owner debe ver y resolver.
TNS-026, TNS-027, TNS-028Revisión y aprobación de evidencia subida por otros responsables. No carga evidencia propia; valida y aprueba o rechaza con motivo.
in_reviewTNS-* con acción aprobada por su rolCada rol mapea a faro.user_roles.role_code. El Dashboard no presenta acciones de otros roles aunque el usuario tenga curiosidad: la query del endpoint filtra por responsible_user_id = $session.userId. Para mirar acciones de otro responsable se requiere rol superior y endpoint distinto (GET /api/v1/responsibles/:user_id/actions).
Los 13 casos de uso principales del Dashboard. Cubren lectura (ver), escritura (modificar) y escalamiento (cuando no puede resolver). Si alguno no está soportado por la UI, hay un bug funcional.
| Caso | Acción | Descripción | Tipo |
|---|---|---|---|
| UC-01 | Ver mis acciones | Lista completa de acciones asignadas al usuario en el período activo. | Lectura |
| UC-02 | Ver vencidas | Filtrar por due_bucket = overdue. Cards marcadas con badge Vencida y borde coral. | Lectura |
| UC-03 | Ver vencen hoy | Filtrar por due_bucket = today. Cards en ámbar fuerte. Urgencia inmediata. | Lectura |
| UC-04 | Ver por prioridad | Toggle multi-select sobre critical, high, medium, low. | Lectura |
| UC-05 | Ver por tensión | Buscador acepta tension_code (ej. TNS-001) y filtra acciones vinculadas. | Lectura |
| UC-06 | Ver evidencia requerida | Panel derecho lista las EVD-* que faltan para cerrar la acción seleccionada. | Lectura |
| UC-07 | Cargar evidencia | Abre modal de carga (FARO-UI-004). Adjunta archivo, comentario o validación según trust level. | Escritura |
| UC-08 | Cambiar estado | new → in_progress → waiting_evidence → in_review. Transiciones controladas por WF-001. | Escritura |
| UC-09 | Pedir validación | Envía acción a estado in_review. Notifica al aprobador asignado por email + push. | Escritura |
| UC-10 | Escalar bloqueo | Genera evento action_escalated en timeline (D4). Levanta acción a jefe directo + alerta dirección. | Escalamiento |
| UC-11 | Justificar demora | Solicita extensión de fecha. Requiere motivo y nueva fecha propuesta. Aprueba gerencia. | Escalamiento |
| UC-12 | Ver impacto Score | Bloque ScoreRecoveryBlock muestra +min a +max puntos potenciales si se cierra la acción. | Lectura |
| UC-13 | Ver historial | Timeline de eventos de la acción y de la tensión asociada. Auditoría completa. | Lectura |
UC-01 a UC-06 y UC-12 a UC-13 son obligatorios para MVP 1. UC-07 a UC-11 caen en MVP 2 (workflow completo). UC-13 con eventos enriquecidos de timeline depende de la tabla faro.action_events (ver sección 7).
El layout es desktop-first con grilla 430px + 1fr a partir de lg:. En mobile la lista pasa a fila única y el detalle se abre como pantalla completa o bottom sheet. Las tablas en celular son el Excel vengándose del usuario.
┌────────────────────────────────────────────────────────────────┐ │ Header: Mi ejecución FARO │ │ Responsable · Período · Score asociado · Acciones críticas │ ├────────────────────────────────────────────────────────────────┤ │ Resumen ejecutivo (5 métricas) │ │ Total · Vencidas · Hoy · Críticas · Recuperación Score │ ├────────────────────────────────────────────────────────────────┤ │ Filtros: prioridad · vencimiento · estado · búsqueda │ ├────────────────────────────┬───────────────────────────────────┤ │ Columna izquierda (430px) │ Panel derecho (1fr) │ │ ──────────────────────────│ ──────────────────────────────────│ │ Mis acciones (lista) │ Detalle acción seleccionada │ │ │ │ │ ┌─────────────────────┐ │ ┌────────────────────────────────┐ │ │ │ ACT-COM-001 │ │ │ Encabezado + Score recovery │ │ │ │ Crítica · Vencida │ │ ├────────────────────────────────┤ │ │ │ Revisar política… │ │ │ Propósito ejecutivo │ │ │ │ TNS-001 · … │ │ ├────────────────────────────────┤ │ │ └─────────────────────┘ │ │ Criterio de cierre │ │ │ ┌─────────────────────┐ │ ├────────────────────────────────┤ │ │ │ ACT-FIN-001 │ │ │ Tensión asociada (link a UI-001)│ │ │ │ Alta · Vence hoy │ │ ├────────────────────────────────┤ │ │ │ Gestionar mora… │ │ │ Evidencia requerida (lista) │ │ │ └─────────────────────┘ │ ├────────────────────────────────┤ │ │ ┌─────────────────────┐ │ │ Aprobador + Vencimiento │ │ │ │ ACT-STK-001 │ │ ├────────────────────────────────┤ │ │ │ Alta · Esta semana │ │ │ Botones: Iniciar · Cargar EVD │ │ │ │ Reponer stock… │ │ │ Enviar revisión · Escalar │ │ │ └─────────────────────┘ │ └────────────────────────────────┘ │ └───────────────────────────┴────────────────────────────────────┘
┌─────────────────────────┐ │ Header compacto │ │ Métricas en 2 columnas │ ├─────────────────────────┤ │ Filtros tipo pills │ │ (scroll horizontal) │ ├─────────────────────────┤ │ Lista de acciones │ │ ┌─────────────────────┐ │ │ │ ACT-COM-001 │ │ │ │ Crítica · Vencida │ │ │ └─────────────────────┘ │ │ ┌─────────────────────┐ │ │ │ ACT-FIN-001 │ │ │ │ Alta · Hoy │ │ │ └─────────────────────┘ │ │ │ │ Tap acción │ │ → detalle pantalla │ │ completa o bottom │ │ sheet │ │ → botón "Cargar │ │ evidencia" fijo abajo │ └─────────────────────────┘
Cada componente tiene una responsabilidad acotada. Si un componente hace dos cosas, se divide. La carpeta components/responsible/ contiene todo lo específico de UI-002; helpers compartidos (priority, action-status, formatters) viven en lib/faro/.
Componente raíz. Gestiona estado de filtros, carga inicial, selección de acción y orquesta los hijos. Single source of truth de la pantalla.
Eyebrow + título + sub + 5 métricas (Total, Vencidas, Hoy, Críticas, Recuperación). Lectura ejecutiva de 3 segundos.
Búsqueda + toggles multi-select de prioridad, vencimiento, estado. Estado controlado por el padre; emite onChange.
Aside scrolleable. Renderiza N ResponsibleActionCard y propaga selección. Max-height calculada para no romper viewport.
Card compacta por acción. Badges de prioridad y vencida, título, código TNS asociado y 3 chips: Estado, Evidencia faltante, Score recovery.
Panel derecho. Encabezado + propósito + criterio de cierre + tensión + evidencia + aprobador + vencimiento + quick actions.
Muestra la tensión asociada: código TNS, título, pregunta de negocio, diagnóstico ejecutivo y priority_score numérico.
Lista de evidencias requeridas con su estado (missing, submitted, approved, rejected, needs_more_info). Color-coded por trust_level.
Modal de carga de evidencia. Acepta archivo, comentario, captura o validación de aprobador. Bloquea cierre si trust_level no se respeta.
Timeline vertical de eventos (action_created, action_started, evidence_uploaded, sent_to_review, action_escalated, etc.).
CTA principal: Iniciar, Cargar evidencia, Enviar a revisión, Escalar bloqueo. Visibilidad condicionada por estado actual de la acción.
Si la acción está blocked, muestra motivo, fecha de bloqueo y responsable de desbloqueo. Sin estos campos la UI rechaza el estado.
Bloque destacado en el encabezado del detalle. Muestra +min a +max puntos. Si la acción está vencida, indica que la recuperación está bloqueada.
Estado vacío. Mensaje sobrio: "No tenés acciones activas asignadas en este período". Nunca dice "todo perfecto".
Banner superior si summary.overdue > 0. Mensaje: "Tenés N acciones vencidas. Sugerencia: priorizar arriba o escalar".
GET /api/v1/me/actionsEndpoint principal del Dashboard. Devuelve el contrato JSON completo: usuario, período, summary, score y items (lista de acciones). Todo lo que la pantalla necesita en una sola request.
| Param | Tipo | Descripción | Ejemplo |
|---|---|---|---|
status | csv enum | Filtro por estado de acción. Lista de valores separados por coma. | new,in_progress,waiting_evidence |
priority | csv enum | Filtro por prioridad. Valores: low, medium, high, critical. | critical,high |
due | csv enum | Filtro por bucket de vencimiento. Valores: overdue, today, this_week, later. | overdue,today |
tension_code | text | Filtro exacto por código TNS canónico. | TNS-001 |
area_code | text | Filtro exacto por área funcional. | commercial |
q | text | Búsqueda libre. Match parcial sobre title, action_code y tension_code. | descuento |
page | int | Paginación. Default: 1. | 1 |
page_size | int | Tamaño de página. Default: 50, máximo: 200. | 50 |
GET /api/v1/me/actions?status=new,in_progress,waiting_evidence&priority=critical,high&due=overdue,today HTTP/1.1 Host: app.farodireccion.com Accept: application/json Cookie: faro_session=<jwt>
{
"user": {
"user_id": "12000000-0000-0000-0000-000000000002",
"full_name": "María Fernández",
"role": "commercial_manager"
},
"period": {
"period_start": "2026-05-01",
"period_end": "2026-05-31",
"label": "Mayo 2026"
},
"summary": {
"total": 8,
"overdue": 2,
"due_today": 1,
"critical": 3,
"high": 4,
"waiting_evidence": 3,
"in_review": 1,
"blocked": 1,
"closed_this_week": 2
},
"score": {
"potential_recovery": 16,
"blocked_recovery": 5,
"recovered_this_week": 4
},
"items": [
{
"action_id": "23000000-0000-0000-0000-000000000001",
"action_code": "ACT-COM-001",
"title": "Revisar política de descuentos",
"description": "Analizar descuentos aplicados por vendedor, producto, cliente y sucursal.",
"status": "new",
"priority": "critical",
"due_date": "2026-06-06",
"is_overdue": false,
"due_bucket": "this_week",
"action_type": "corrective",
"closure_criteria": "Nueva política de descuentos aprobada por dirección y comunicada al equipo comercial.",
"expected_impact": "Mejora de margen, disciplina comercial y rentabilidad.",
"expected_score_recovery_min": 3,
"expected_score_recovery_max": 8,
"tension": {
"tension_id": "22000000-0000-0000-0000-000000000001",
"tension_code": "TNS-001",
"title": "Crecimiento no rentable",
"severity": "critical",
"priority_score": 92,
"score_impact": -8.5
},
"evidence": {
"required": true,
"required_codes": ["EVD-007", "EVD-012"],
"missing": 2,
"submitted": 0,
"approved": 0,
"requirements": [
{
"evidence_code": "EVD-007",
"name": "Cambio de política",
"trust_level": "critical",
"status": "missing"
},
{
"evidence_code": "EVD-012",
"name": "Validación de dirección",
"trust_level": "critical",
"status": "missing"
}
]
},
"approver": {
"user_id": "12000000-0000-0000-0000-000000000001",
"full_name": "Tomás Pombo"
}
}
]
}
Cuando el usuario selecciona una acción y se requiere información extendida (timeline completo, descripción de evidencia con definición, business question de la tensión), se llama a:
GET /api/v1/actions/:action_id HTTP/1.1 Host: app.farodireccion.com Accept: application/json
El response amplía el item base con tension.business_question, tension.executive_diagnosis, evidence_requirements[].description y timeline[] de eventos auditados (vienen de faro.action_events).
faro.v_responsible_action_dashboardVista materializada lógica (no MATERIALIZED VIEW) que enriquece faro.actions con definición de catálogo (FARO-SQL-005), tensión asociada (FARO-SQL-004 + datos), usuarios responsables y aprobadores, y conteo agregado de evidencia desde v_action_evidence_status.
Por qué vista, no tabla. Las acciones cambian de estado seguido; un campo derivado como is_overdue o due_bucket calculado en una columna persistida quedaría desincronizado. La vista garantiza que cada lectura refleja CURRENT_DATE al momento del query.
CREATE OR REPLACE VIEW faro.v_responsible_action_dashboard AS SELECT a.company_id, a.action_id, a.action_code, ad.name AS catalog_name, a.title, a.description, a.action_type, a.status, a.priority, a.due_date, CASE WHEN a.status NOT IN ('closed', 'cancelled', 'rejected') AND a.due_date < CURRENT_DATE THEN true ELSE false END AS is_overdue, CASE WHEN a.due_date < CURRENT_DATE THEN 'overdue' WHEN a.due_date = CURRENT_DATE THEN 'today' WHEN a.due_date <= CURRENT_DATE + INTERVAL '7 days' THEN 'this_week' ELSE 'later' END AS due_bucket, a.responsible_user_id, ru.full_name AS responsible_name, ru.email AS responsible_email, a.approver_user_id, au.full_name AS approver_name, au.email AS approver_email, a.evidence_required, a.closure_criteria, a.expected_impact, a.expected_impact_amount, ad.executive_purpose, ad.success_metric, ad.expected_business_impact, ad.expected_score_recovery_min, ad.expected_score_recovery_max, ad.score_dimension AS action_score_dimension, t.tension_id, t.tension_code, t.title AS tension_title, t.severity AS tension_severity, t.priority_score AS tension_priority_score, t.score_impact AS tension_score_impact, td.business_question, td.executive_diagnosis, td.area_code, td.module_code, COALESCE(ev_summary.required_count, 0) AS evidence_required_count, COALESCE(ev_summary.submitted_count, 0) AS evidence_submitted_count, COALESCE(ev_summary.approved_count, 0) AS evidence_approved_count, GREATEST( COALESCE(ev_summary.required_count, 0) - COALESCE(ev_summary.approved_count, 0), 0 ) AS evidence_missing_count, a.payload FROM faro.actions a LEFT JOIN faro.action_definitions ad ON ad.action_code = a.action_code AND ad.status = 'active' LEFT JOIN faro.tensions t ON t.tension_id = a.tension_id AND t.company_id = a.company_id LEFT JOIN faro.tension_definitions td ON td.tension_code = t.tension_code AND td.status = 'active' LEFT JOIN faro.users ru ON ru.user_id = a.responsible_user_id LEFT JOIN faro.users au ON au.user_id = a.approver_user_id LEFT JOIN ( SELECT aes.company_id, aes.action_id, COUNT(*) AS required_count, COUNT(*) FILTER (WHERE aes.evidence_status IN ('submitted', 'approved')) AS submitted_count, COUNT(*) FILTER (WHERE aes.evidence_status = 'approved') AS approved_count FROM faro.v_action_evidence_status aes GROUP BY aes.company_id, aes.action_id ) ev_summary ON ev_summary.company_id = a.company_id AND ev_summary.action_id = a.action_id;
faro.action_eventsTabla auxiliar para timeline y auditoría. Se crea en migración paralela V035. Recibe cada evento del workflow: action_created, action_started, evidence_uploaded, sent_to_review, evidence_approved, evidence_rejected, action_blocked, action_escalated, extension_requested, action_closed. El evento escalated es evento de timeline (D4), no estado terminal.
CREATE TABLE IF NOT EXISTS faro.action_events ( action_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, action_id uuid NOT NULL, event_type text NOT NULL, actor_user_id uuid NULL, from_status text NULL, to_status text NULL, title text NOT NULL, description text NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT action_events_event_type_check CHECK ( event_type IN ( 'action_created', 'action_started', 'evidence_uploaded', 'sent_to_review', 'evidence_approved', 'evidence_rejected', 'action_blocked', 'action_escalated', 'extension_requested', 'action_closed' ) ) ); CREATE INDEX idx_action_events_action ON faro.action_events (company_id, action_id, created_at DESC); CREATE INDEX idx_action_events_actor ON faro.action_events (company_id, actor_user_id, created_at DESC);
El ORDER BY de esta query es ley del MVP. Define qué ve el responsable cuando entra al Dashboard. Si la UI ordena distinto, está rota.
| Bucket | Criterio | Tratamiento visual | Desempate |
|---|---|---|---|
| 1 | Overdue + critical | Borde coral oscuro + badge "Vencida" + fondo rojizo tenue | due_date ASC |
| 2 | Overdue (cualquier prioridad) | Borde coral + badge "Vencida" | due_date ASC |
| 3 | Due bucket = today | Borde ámbar + chip "Hoy" | priority DESC, due_date ASC |
| 4 | Critical (no vencida) | Badge critical (rojo sólido) | due_date ASC |
| 5 | High | Badge high (coral suave) | due_date ASC |
| 6 | Resto (medium, low) | Badge neutral | tension_priority_score DESC, due_date ASC |
SELECT action_id, action_code, COALESCE(title, catalog_name) AS title, description, action_type, status, priority, due_date, is_overdue, due_bucket, closure_criteria, expected_impact, expected_score_recovery_min, expected_score_recovery_max, tension_id, tension_code, tension_title, tension_severity, tension_priority_score, tension_score_impact, business_question, executive_diagnosis, evidence_required, evidence_required_count, evidence_submitted_count, evidence_approved_count, evidence_missing_count, approver_user_id, approver_name, approver_email, payload FROM faro.v_responsible_action_dashboard WHERE company_id = $1 AND responsible_user_id = $2 AND ($3::text[] IS NULL OR status = ANY($3)) AND ($4::text[] IS NULL OR priority = ANY($4)) AND ($5::text[] IS NULL OR due_bucket = ANY($5)) AND ($6::text IS NULL OR area_code = $6) AND ( $7::text IS NULL OR lower(title) LIKE '%' || lower($7) || '%' OR lower(action_code) LIKE '%' || lower($7) || '%' OR lower(tension_code) LIKE '%' || lower($7) || '%' ) ORDER BY CASE WHEN is_overdue = true AND priority = 'critical' THEN 1 WHEN is_overdue = true THEN 2 WHEN due_bucket = 'today' THEN 3 WHEN priority = 'critical' THEN 4 WHEN priority = 'high' THEN 5 ELSE 6 END, due_date ASC, tension_priority_score DESC LIMIT $8 OFFSET $9;
El bloque summary del response se computa en una sola query agregada para evitar contar en el frontend (lo que sería frágil con paginación):
SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE is_overdue = true)::int AS overdue, COUNT(*) FILTER (WHERE due_bucket = 'today')::int AS due_today, COUNT(*) FILTER (WHERE priority = 'critical')::int AS critical, COUNT(*) FILTER (WHERE priority = 'high')::int AS high, COUNT(*) FILTER (WHERE evidence_missing_count > 0)::int AS waiting_evidence, COUNT(*) FILTER (WHERE status = 'in_review')::int AS in_review, COUNT(*) FILTER (WHERE status = 'blocked')::int AS blocked, COUNT(*) FILTER ( WHERE status = 'closed' AND due_date >= CURRENT_DATE - INTERVAL '7 days' )::int AS closed_this_week, COALESCE(SUM(expected_score_recovery_max), 0)::numeric AS potential_recovery, COALESCE(SUM(expected_score_recovery_max) FILTER (WHERE status = 'blocked'), 0)::numeric AS blocked_recovery FROM faro.v_responsible_action_dashboard WHERE company_id = $1 AND responsible_user_id = $2 AND status NOT IN ('cancelled', 'rejected');
TypeScript estricto es la red de seguridad del MVP. Sin tipos, cualquier cambio en el contrato API rompe la UI silenciosamente y se descubre en producción. Estos tres archivos son obligatorios.
export type ActionPriority = "low" | "medium" | "high" | "critical"; // Status enum único — coordinado con WF-001 · Workflow de Acción. // El evento `escalated` es un evento de timeline (D4), NO un estado terminal. export type ResponsibleActionStatus = | "new" | "in_progress" | "blocked" | "waiting_evidence" | "in_review" | "approved" | "closed" | "expired" | "cancelled" | "rejected"; export type DueBucket = "overdue" | "today" | "this_week" | "later"; export type EvidenceRequirementStatus = { evidence_code: string; name: string; trust_level: "low" | "medium" | "high" | "critical"; status: "missing" | "submitted" | "approved" | "rejected" | "needs_more_info"; }; export type ResponsibleActionItem = { action_id: string; action_code: string; title: string; description: string | null; status: ResponsibleActionStatus; priority: ActionPriority; due_date: string | null; is_overdue: boolean; due_bucket: DueBucket; action_type: string; closure_criteria: string | null; expected_impact: string | null; expected_score_recovery_min: number | null; expected_score_recovery_max: number | null; tension: { tension_id: string | null; tension_code: string | null; title: string | null; severity: string | null; priority_score: number | null; score_impact: number | null; business_question?: string | null; executive_diagnosis?: string | null; }; evidence: { required: boolean; required_codes: string[]; missing: number; submitted: number; approved: number; requirements: EvidenceRequirementStatus[]; }; approver: { user_id: string | null; full_name: string | null; email?: string | null; }; }; export type ResponsibleDashboardResponse = { user: { user_id: string; full_name: string; role: string; }; period: { period_start: string; period_end: string; label: string; }; summary: { total: number; overdue: number; due_today: number; critical: number; high: number; waiting_evidence: number; in_review: number; blocked: number; closed_this_week: number; }; score: { potential_recovery: number; blocked_recovery: number; recovered_this_week: number; }; items: ResponsibleActionItem[]; };
import type { ActionPriority } from "./responsible.types"; export function priorityLabel(priority: ActionPriority): string { const map: Record<ActionPriority, string> = { critical: "Crítica", high: "Alta", medium: "Media", low: "Baja" }; return map[priority] ?? priority; } export function priorityTone(priority: ActionPriority) { const map = { critical: { badge: "bg-red-100 text-red-800 border border-red-200", border: "border-red-400", tag: "critical" }, high: { badge: "bg-amber-100 text-amber-800 border border-amber-200", border: "border-amber-300", tag: "high" }, medium: { badge: "bg-yellow-100 text-yellow-800 border border-yellow-200", border: "border-yellow-300", tag: "medium" }, low: { badge: "bg-slate-100 text-slate-700 border border-slate-200", border: "border-slate-300", tag: "low" } }; return map[priority] ?? map.low; } // Orden canónico de prioridad para tie-breakers en frontend. export const PRIORITY_RANK: Record<ActionPriority, number> = { critical: 4, high: 3, medium: 2, low: 1 };
import type { ResponsibleActionStatus } from "./responsible.types"; export function actionStatusLabel(status: ResponsibleActionStatus): string { const map: Record<ResponsibleActionStatus, string> = { new: "Nueva", in_progress: "En ejecución", blocked: "Bloqueada", waiting_evidence: "Sin evidencia", in_review: "En revisión", approved: "Aprobada", closed: "Cerrada", expired: "Vencida", cancelled: "Cancelada", rejected: "Rechazada" }; return map[status] ?? status; } // Estados en los que el responsable puede cargar evidencia. export const EVIDENCE_UPLOADABLE_STATUSES: ResponsibleActionStatus[] = [ "new", "in_progress", "waiting_evidence", "expired" ]; // Estados que NO se pueden cerrar sin evidencia aprobada. export function canCloseAction( status: ResponsibleActionStatus, evidenceMissing: number, evidenceRequired: boolean ): boolean { if (status === "closed" || status === "cancelled") return false; if (evidenceRequired && evidenceMissing > 0) return false; return status === "approved" || status === "in_review"; }
import type { ResponsibleDashboardResponse } from "./responsible.types"; export type GetResponsibleActionsParams = { status?: string[]; priority?: string[]; due?: string[]; area_code?: string; q?: string; page?: number; page_size?: number; }; export async function getResponsibleActions( params: GetResponsibleActionsParams = {} ): Promise<ResponsibleDashboardResponse> { const search = new URLSearchParams(); if (params.status?.length) search.set("status", params.status.join(",")); if (params.priority?.length) search.set("priority", params.priority.join(",")); if (params.due?.length) search.set("due", params.due.join(",")); if (params.area_code) search.set("area_code", params.area_code); if (params.q) search.set("q", params.q); if (params.page) search.set("page", String(params.page)); if (params.page_size) search.set("page_size", String(params.page_size)); const response = await fetch(`/api/v1/me/actions?${search.toString()}`, { method: "GET", headers: { "Content-Type": "application/json" }, cache: "no-store" }); if (!response.ok) { throw new Error("No se pudo cargar tu tablero de acciones"); } return response.json(); } export async function updateActionStatus(actionId: string, status: string) { const response = await fetch(`/api/v1/actions/${actionId}/status`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }) }); if (!response.ok) { throw new Error("No se pudo actualizar la acción"); } return response.json(); }
app/api/v1/me/actions/route.tsRoute handler completo con sesión, RLS por set_config, query a vista canónica, summary agregado y mapeo a contrato JSON. Es el patrón de referencia para todos los endpoints "me" del MVP.
Regla de seguridad. Este endpoint usa session.user_id directamente del JWT verificado. No acepta responsible_user_id como query param. Cualquier intento de cambiar la identidad efectiva se rechaza con 401. Para gerentes y directores existen endpoints distintos con permisos superiores y auditoría diferenciada (ver sección 12).
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 status = search.get("status")?.split(",") ?? null; const priority = search.get("priority")?.split(",") ?? null; const due = search.get("due")?.split(",") ?? null; const areaCode = search.get("area_code"); const q = search.get("q"); const page = Number(search.get("page") ?? 1); const pageSize = Math.min(Number(search.get("page_size") ?? 50), 200); const offset = (page - 1) * pageSize; const client = await db.connect(); try { await client.query("BEGIN"); // RLS: bind session into Postgres GUC variables. // Row Level Security policies de faro.actions leen estos valores. 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 actions = await client.query( ` SELECT * FROM faro.v_responsible_action_dashboard WHERE company_id = $1 AND responsible_user_id = $2 AND ($3::text[] IS NULL OR status = ANY($3)) AND ($4::text[] IS NULL OR priority = ANY($4)) AND ($5::text[] IS NULL OR due_bucket = ANY($5)) AND ($6::text IS NULL OR area_code = $6) AND ( $7::text IS NULL OR lower(title) LIKE '%' || lower($7) || '%' OR lower(action_code) LIKE '%' || lower($7) || '%' OR lower(tension_code) LIKE '%' || lower($7) || '%' ) ORDER BY CASE WHEN is_overdue = true AND priority = 'critical' THEN 1 WHEN is_overdue = true THEN 2 WHEN due_bucket = 'today' THEN 3 WHEN priority = 'critical' THEN 4 WHEN priority = 'high' THEN 5 ELSE 6 END, due_date ASC, tension_priority_score DESC LIMIT $8 OFFSET $9 `, [session.companyId, session.userId, status, priority, due, areaCode, q, pageSize, offset] ); const summary = await client.query( ` SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE is_overdue = true)::int AS overdue, COUNT(*) FILTER (WHERE due_bucket = 'today')::int AS due_today, COUNT(*) FILTER (WHERE priority = 'critical')::int AS critical, COUNT(*) FILTER (WHERE priority = 'high')::int AS high, COUNT(*) FILTER (WHERE evidence_missing_count > 0)::int AS waiting_evidence, COUNT(*) FILTER (WHERE status = 'in_review')::int AS in_review, COUNT(*) FILTER (WHERE status = 'blocked')::int AS blocked, COUNT(*) FILTER ( WHERE status = 'closed' AND due_date >= CURRENT_DATE - INTERVAL '7 days' )::int AS closed_this_week, COALESCE(SUM(expected_score_recovery_max), 0)::numeric AS potential_recovery, COALESCE(SUM(expected_score_recovery_max) FILTER (WHERE status = 'blocked'), 0)::numeric AS blocked_recovery FROM faro.v_responsible_action_dashboard WHERE company_id = $1 AND responsible_user_id = $2 AND status NOT IN ('cancelled', 'rejected') `, [session.companyId, session.userId] ); await client.query("COMMIT"); return NextResponse.json({ user: { user_id: session.userId, full_name: session.fullName, role: session.primaryRole }, period: { period_start: "2026-05-01", period_end: "2026-05-31", label: "Mayo 2026" }, summary: { total: summary.rows[0].total, overdue: summary.rows[0].overdue, due_today: summary.rows[0].due_today, critical: summary.rows[0].critical, high: summary.rows[0].high, waiting_evidence: summary.rows[0].waiting_evidence, in_review: summary.rows[0].in_review, blocked: summary.rows[0].blocked, closed_this_week: summary.rows[0].closed_this_week }, score: { potential_recovery: Number(summary.rows[0].potential_recovery ?? 0), blocked_recovery: Number(summary.rows[0].blocked_recovery ?? 0), recovered_this_week: 0 }, items: actions.rows.map(mapResponsibleActionRow) }); } catch (error) { await client.query("ROLLBACK"); return NextResponse.json( { error: "Could not load responsible actions" }, { status: 500 } ); } finally { client.release(); } } function mapResponsibleActionRow(row: any) { const payload = row.payload ?? {}; const requirements = payload.evidence_requirements ?? []; return { action_id: row.action_id, action_code: row.action_code, title: row.title ?? row.catalog_name, description: row.description, status: row.status, priority: row.priority, due_date: row.due_date, is_overdue: row.is_overdue, due_bucket: row.due_bucket, action_type: row.action_type, closure_criteria: row.closure_criteria, expected_impact: row.expected_impact ?? row.expected_business_impact, expected_score_recovery_min: row.expected_score_recovery_min ? Number(row.expected_score_recovery_min) : null, expected_score_recovery_max: row.expected_score_recovery_max ? Number(row.expected_score_recovery_max) : null, tension: { tension_id: row.tension_id, tension_code: row.tension_code, title: row.tension_title, severity: row.tension_severity, priority_score: row.tension_priority_score ? Number(row.tension_priority_score) : null, score_impact: row.tension_score_impact ? Number(row.tension_score_impact) : null, business_question: row.business_question, executive_diagnosis: row.executive_diagnosis }, evidence: { required: row.evidence_required, required_codes: payload.evidence_required_codes ?? [], missing: Number(row.evidence_missing_count ?? 0), submitted: Number(row.evidence_submitted_count ?? 0), approved: Number(row.evidence_approved_count ?? 0), requirements: requirements.map((item: any) => ({ evidence_code: item.evidence_code, name: item.name, trust_level: item.trust_level, status: "missing" })) }, approver: { user_id: row.approver_user_id, full_name: row.approver_name, email: row.approver_email } }; }
Para cerrar el circuito, así se monta el handler en la app router de Next.js. La página vive en app/faro/my-actions/page.tsx y monta el componente cliente:
import { ResponsibleDashboardPage } from "@/components/responsible/ResponsibleDashboardPage"; export const metadata = { title: "Mis acciones · FARO Connect", description: "Vista diaria de acciones asignadas, vencimientos y recuperación de Score." }; export default function Page() { return <ResponsibleDashboardPage />; }
Mockup estático del Dashboard tal como lo vería la responsable comercial de Empresa Demo Cuyo S.A. al entrar el martes 2 de junio de 2026. 7 acciones asignadas, 2 vencidas, 1 vence hoy, 3 críticas. Recuperación potencial: +16 puntos de FARO Score.
María Fernández · Gerencia Comercial · Empresa Demo Cuyo S.A. · Mayo 2026. Vista de acciones, vencimientos, evidencia pendiente y recuperación potencial de Score.
TNS-002 · Descuento fuera de política
TNS-001 · Crecimiento no rentable
TNS-003 · Vendedor erosiona margen
TNS-013 · Caída de ventas en sucursal
TNS-011 · Ventas concentradas en pocos clientes
TNS-014 · Margen bajo por familia
TNS-012 · Ticket promedio cae
Mockup estático con datos demostrativos de Empresa Demo Cuyo S.A. No representa empresa real. Estados, vencimientos y Score recovery son ilustrativos del contrato visual; los códigos ACT-*, TNS-* y EVD-* sí son canónicos y resuelven contra los catálogos MVP.
El Dashboard Responsable no es una pantalla "para todos". Tiene matriz de permisos clara y se apoya en 9 endpoints REST que cubren consulta y todo el workflow operativo de una acción.
Regla canónica de visibilidad. Un responsable ve lo suyo. Un gerente ve lo de su área. Un director ve todo. El endpoint /api/v1/me/actions siempre devuelve solo lo del usuario en sesión. Para que un gerente vea acciones ajenas, debe usar /api/v1/responsibles/:user_id/actions con su rol elevado.
| Acción | Responsable | Gerente | Director | Aprobador |
|---|---|---|---|---|
| Ver mis acciones | Sí | Sí | Sí | Sí |
| Ver acciones de otros | No | Sí (su área) | Sí | Solo en revisión |
| Iniciar acción | Sí | Sí | Sí | No |
| Cargar evidencia | Sí | Sí | Sí | No |
| Bloquear acción | Sí | Sí | Sí | No |
| Cambiar responsable | No | Sí (su área) | Sí | No |
| Aprobar evidencia | Solo si tiene rol | Sí | Sí | Sí |
| Cerrar acción | Parcial | Sí | Sí | Si fue aprobador |
| Escalar acción | Sí | Sí | Sí | No |
| Rechazar evidencia | No | Sí | Sí | Sí |
| Solicitar extensión fecha | Solicita | Aprueba | Aprueba | No |
Los 9 endpoints que la UI consume directa o indirectamente. GET para lectura, POST para acciones idempotentes, PATCH para mutaciones parciales. Todos requieren sesión válida con company_id y user_id.
action_escalated (D4) y dispara notificación a jefe directo y Dirección.in_review. Notifica al aprobador. Requiere al menos una evidencia submitted.Cada uno de los 8 endpoints de escritura inserta un row en faro.action_events (ver sección 7). Eventos típicos: action_started, evidence_uploaded, sent_to_review, action_blocked, action_escalated, extension_requested, action_closed. Esta tabla es la fuente única del timeline visible en el detalle y de los reportes de SLA por área (MVP 4).
UI-002 no vive sola. Consume catálogos canónicos, alimenta workflows y se complementa con otras pantallas del MVP. Estos son los puntos donde se conecta el Dashboard Responsable con el resto del pipeline FARO Connect.
Bandeja de Tensiones MVP. Vista colectiva de la empresa. UI-002 es la cara individual; UI-001 es la cara organizacional. Comparten v_tension_board y conviven sin solapar audiencia.
Detalle de Acción + Workflow completo. Pantalla a la que se navega desde el Dashboard para ejecutar el ciclo completo: cambiar estado, aprobar evidencia, cerrar.
Modal o pantalla de carga de evidencia. Se invoca desde el botón "Cargar evidencia" del Dashboard. Valida metadata, archivo y trust_level antes de persistir.
Workflow canónico de escalamiento. Define qué pasa cuando una acción se escala (D4): notificación a jefe directo, alerta dirección, marcado en timeline.
Modelo FARO Score. Define cómo se calcula expected_score_recovery_min/max que el Dashboard muestra en cada acción y en el header global.
Catálogo Canónico de Acciones MVP. Cada acción mostrada en el Dashboard resuelve contra una ACT-* de este catálogo (propósito, criterio cierre, evidencia requerida).
30 tensiones canónicas TNS-001..TNS-030. Cada acción del Dashboard apunta a una tensión asociada con su severidad, priority_score y diagnosis.
Catálogo de evidencias EVD-001..EVD-012. La lista "Evidencia requerida" del detalle de acción resuelve sus códigos contra este catálogo y muestra trust_level.
DDL completo del sistema. Incluye faro.actions, faro.tensions, faro.evidence, faro.action_events y la vista v_responsible_action_dashboard de sección 7.
FARO-UI-002 convierte la inteligencia de FARO en responsabilidad concreta. La tensión dice qué está mal. La acción dice qué hacer. El Dashboard dice quién debe hacerlo hoy. La evidencia demuestra si se hizo. Sin esta vista, FARO puede detectar mucho y ejecutar poco.
→ Volver al hub modelos NDA