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.
Primera pantalla operativa visible de FARO Connect. Convierte el motor evaluador en gestión visible: lista priorizada, detalle ejecutivo, acciones recomendadas, evidencia requerida e impacto Score. No es un dashboard de KPIs lindos: es una mesa de control ejecutivo-operativa.
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.
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.
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.
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.
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.
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.
Si la tension no tiene responsable asignado, se ofrece accion ACT-OPS-003 automaticamente. Tension sin responsable es tension huerfana y eso no escala.
Cerrar tension sin evidencia rompe el modelo. La bandeja muestra evidence_required antes que cualquier boton "cerrar". FARO premia evidencia, no relato.
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.
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;
Coordinados con workflow-escalamiento-mvp (WF-001). escalated no entra en el enum: es evento auditable, no estado de fondo.
Tension recien detectada por el motor evaluador. Falta tomarla.
El responsable la esta analizando y aun no inicia ejecucion.
Ya tiene acciones creadas y en curso operativo.
Acciones ejecutadas, falta validar evidencia para cerrar.
Cerrada con evidencia suficiente y aprobador validando.
Vencida sin resolucion. Se mantiene visible y afecta Score.
Rechazada con justificacion ejecutiva. Salida formal del flujo.
Cancelada por cambio de contexto. Diferente a rechazada.
Pausa temporal con razon registrada. No cuenta como activa.
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.
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.
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).
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.
Orquestador. Hace fetch al endpoint, mantiene estado de filtros y seleccion, decide loading/error/empty. Unico componente con efectos.
Empresa, periodo, FARO Score, delta semanal, totales por severidad y vencidas. Lectura ejecutiva de un vistazo, sin scroll.
Input de busqueda libre + toggles por severidad + toggles por estado. Sticky en mobile para no perder filtros al scrollear.
Renderiza cards en orden canonico. Scroll independiente del panel detalle. Mantiene seleccion accesible con teclado.
Codigo, severidad, prioridad, titulo, pregunta ejecutiva, responsable, vence, acciones, score. Borde lateral por severidad.
Encabezado + diagnostico + KPIs + acciones + evidencia + responsable + score impact + acciones rapidas. Padre de subcomponentes.
Renderiza business_question y executive_diagnosis desde catalogo canonico. Nunca hardcodea texto.
Lista de KPIs con valor actual vs referencia, delta, condicion evaluada y estado (ok/warn/critical). Explica el por que.
Renderiza acciones asociadas (creadas + recomendadas). Cada accion expone su evidencia, fecha limite y responsable.
Codigo, titulo, criterio de cierre, evidencia requerida, prioridad, due_date y recuperacion Score esperada. Unidad ejecutable.
Lista de codigos EVD-* requeridos con nivel de confianza, estado (missing/partial/uploaded) y CTA cargar.
Muestra impacto negativo actual + recuperacion potencial si se ejecutan acciones. Barra de prioridad 0-100.
Eventos desde deteccion: cambios de estado, asignaciones, evidencia cargada, escalamientos. Auditoria visible.
Solo visible si la tension fue escalada (evento, no estado). Muestra a quien, cuando, por que y respuesta esperada.
Sin tensiones activas no dice "todo perfecto". Dice "no se detectaron condiciones que requieran intervencion". Prudencia ejecutiva.
Skeleton estructural, no spinner generico. Replica forma de cards y header para evitar saltos visuales al cargar.
Mensajes diferenciados: API caida, sin permisos, sin company context, catalogo inconsistente, datos incompletos. CTA reintentar.
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.
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.
{
"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 }
}
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.
{
"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" }
]
}
| Metodo | Endpoint | Uso | Rol minimo |
|---|---|---|---|
GET | /api/v1/tensions | Listar tensiones (paginado, filtrable) | Responsable+ |
GET | /api/v1/tensions/:id | Detalle completo con KPIs, acciones y timeline | Responsable+ |
PATCH | /api/v1/tensions/:id/status | Cambiar estado operativo | Responsable+ |
PATCH | /api/v1/tensions/:id/assign | Reasignar responsable | GerenteG+ |
POST | /api/v1/tensions/:id/escalate | Escalar a direccion (evento) | GerenteG+ |
GET | /api/v1/tensions/:id/actions | Listar acciones asociadas | Responsable+ |
POST | /api/v1/actions/:id/evidence | Cargar evidencia para accion | Responsable+ |
PATCH | /api/v1/actions/:id/status | Cambiar estado accion | Responsable+ |
GET | /api/v1/catalogs/tensions | Catalogo TNS canonico | Cualquiera |
GET | /api/v1/catalogs/actions | Catalogo ACT canonico | Cualquiera |
GET | /api/v1/catalogs/evidence | Catalogo EVD canonico | Cualquiera |
v_tension_inboxLa 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.
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.
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.
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)
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.
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.
// ============================================================ // 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[]; };
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.
import { TensionInboxPage } from "@/components/tensions/TensionInboxPage"; export default function Page() { return <TensionInboxPage />; }
"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> ); }
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> ); }
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]); }
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> ); }
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> ); }
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.
Empresa Demo Cuyo S.A. · Mayo 2026. Esta vista prioriza tensiones activas, responsables, acciones y evidencia pendiente.
"Estamos vendiendo mas pero ganando menos?"
"La venta crece, pero la caja no aparece?"
"Por que decidimos algo si despues no se ejecuta?"
"Estamos perdiendo venta por falta de producto?"
"Cerramos sin pruebas o con narrativa?"
La empresa crece en volumen, pero sacrifica rentabilidad. No es crecimiento sano: es crecimiento comprado con margen. Las ventas crecieron 18%, pero el margen bruto cayo de 28% a 21% y el descuento promedio subio.
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.
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.
| Accion | Roles permitidos | Notas |
|---|---|---|
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. |
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.
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.
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.
Ventas 36.1 M (+18%) pero margen bruto pasa de 28% a 21% y descuento promedio sube 14.5%. La empresa crece comprando margen.
Facturacion sube, pero DSO crece de 38 a 54 dias. El crecimiento esta financiando al cliente y la caja proyectada se compromete.
Cobertura promedio < 5 dias en 12 SKUs A. Riesgo de quiebre con perdida estimada de venta semanal de 1.2 M.
18% del stock valorizado en SKUs sin movimiento >120 dias. Caja inmovilizada estimada: 4.8 M. Genera pesadez financiera silenciosa.
4 de 5 acciones de decisiones previas vencidas sin reaccion. Sin responsable asignado. Tension de gobierno ejecutivo.
3 acciones marcadas cerradas por responsable sin EVD-* cargada. Riesgo de cierre ficticio y reincidencia.
Muestra tensiones activas ordenadas por severidad x antiguedad x prioridad.
Click en card actualiza panel derecho con diagnostico, acciones, evidencia.
Cada card declara severidad con borde lateral + badge sobrio (no semaforo infantil).
Si falta responsable, se muestra alerta y CTA asignar.
Cada tension expone score_impact negativo y recuperacion potencial.
Cada tension expone su business_question desde catalogo canonico.
Codigos ACT-* visibles, clickeables a detalle de accion.
Codigos EVD-* visibles con estado missing/uploaded.
Toggle chips funcional sin recarga, persistencia en URL.
Input que busca en titulo y codigo, debounce 300ms.
Estados diferenciados con copy ejecutivo, sin "todo perfecto".
Mobile, tablet, desktop, large. Sin tablas anchas en mobile.
| Metrica UX | Objetivo | Como se mide |
|---|---|---|
| Time-to-first-decision | < 90 segundos desde login | Tiempo entre cargar bandeja y primer cambio de estado de tension. |
| Tensiones revisadas / sesion | >= 80% del total activo | Cards con click vs total renderizado. |
| Acciones creadas / tension critica | >= 1 por sesion | POST /api/v1/actions por sesion / criticas vistas. |
| Evidencia cargada / accion cerrada | 100% con evidencia | Acciones 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 tensiones | Web vitals + tiempo de query SQL. |
| Tensiones sin responsable activas | 0 sostenido | Conteo de responsible_user_id IS NULL + alerta ejecutiva. |
| Tasa de escalamiento sano | 5-15% del total | Mas 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.
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.
30 tensiones canonicas TNS-001..TNS-030. Fuente de nombre, area, severidad base, pregunta ejecutiva y dimension Score.
Catalogo ACT canonico. Los recommended_actions de cada tension resuelven aqui.
Catalogo EVD canonico con niveles de confianza y estados missing/partial/uploaded/approved.
Capa RLS PostgreSQL que aisla por company_id y rol. La bandeja confia en estas policies para no exponer datos cruzados.
DDL completo del sistema. Define tablas tensions, actions, evidence, users que la vista v_tension_inbox agrega.
Definicion oficial de los 10 estados y del evento escalated. Spec hermana de esta pieza, aun en construccion.
Dashboard personal de cada responsable: mis tensiones, mis acciones, vencimientos, evidencias pendientes. Siguiente pantalla del MVP.
Pantalla individual de una accion: criterio de cierre, evidencia, cambio de estado, recuperacion Score esperada.
Motor que dispara las tensiones leidas por la bandeja. Define priority_score, confidence_score y score_impact.
Diagnostico ejecutivo trimestral que toma como insumo las tensiones cerradas y abiertas que vive la bandeja.
Reporte semanal automatico que resume la bandeja para direccion. Misma fuente, formato comprimido.
Contrato de datos minimo del MVP. Sin estas fuentes pobladas, la bandeja se rinde con datos insuficientes.
FARO-UI-001 define el contrato completo (UX, datos, SQL, tipos, componentes, mockup, permisos) para que el equipo de producto pueda implementar la bandeja sin reabrir decisiones. Las piezas pendientes (workflow, dashboard responsable, motor evaluador) suceden a partir de esta base.
→ Volver al hub modelos NDA