Nueva
Acción creada por el motor FARO o por intervención manual. Todavía no la tomó nadie.
Botón: Iniciar acciónLa pantalla donde una acción FARO pasa de tarea a ejecución verificable: ver, iniciar, cargar evidencia, enviar a revisión, aprobar o rechazar, cerrar e impactar el FARO Score. Una acción no se cierra porque alguien diga que está hecha; se cierra cuando cumple su criterio y tiene evidencia válida.
Esta UI convierte a FARO Connect en sistema de ejecución, no solo de diagnóstico. Una tensión dice hay un problema; una acción dice esto hay que hacer; el workflow dice quién lo hace, antes de cuándo, con qué prueba y quién lo valida. Sin este flujo, FARO queda elegante pero flojo de control, y la elegancia sin control es PowerPoint caro.
FARO-UI-003 es la pantalla donde una acción se ejecuta de punta a punta. Convierte una acción en una operación trazable: el responsable la toma, carga evidencia, envía a revisión, el aprobador valida o rechaza, el gerente cierra y el sistema impacta el FARO Score. Cada paso queda registrado en execution_events con actor, transición y payload.
La pieza descansa sobre seis decisiones canónicas:
workflow-escalamiento-mvp.html (WF-001). Cualquier transición fuera del set se rechaza con INVALID_STATUS_TRANSITION.EVD-009 · Cierre manual justificado, y queda auditado con menor nivel de confianza.execution_events (D3 aplicado). 40 event_types con CHECK constraint cubren acción + evidencia + escalamiento + extensión + decisión. action_events queda como vista filtrada, no como tabla paralela.escalated no entra al enum de estados para no inflar el workflow; vive como event_type = 'action_escalated'.FOR UPDATE, valida transición, ejecuta, registra evento y commitea. La UI puede ocultar botones, pero el backend decide.faro.can_close_action(p_action_id). Una sola función PL/pgSQL devuelve (can_close boolean, blocking_reasons text[]) y la consumen frontend, backend y tests por igual.Esta pieza es crítica porque es donde el modelo FARO toca la realidad operativa: si esta UI deja cerrar acciones sin evidencia, todo el sistema pierde sentido. El motor evaluador puede detectar tensiones perfectas, los catálogos pueden estar inmaculados, los reportes pueden verse premium, pero si una acción crítica se cierra con "ya está hecho, confiá", FARO se convierte en PowerPoint caro.
El ciclo de vida de una acción FARO es un flujo lineal de siete etapas. Cada una tiene actor, evento y precondiciones. Cada una deja huella en execution_events. El cierre solo es válido si todas las precondiciones están cumplidas y la evidencia está aprobada.
Regla central. Una acción FARO no está terminada porque alguien la marque como hecha. Está terminada cuando cumple su criterio de cierre y tiene evidencia válida. La diferencia entre marcada como hecha y terminada es exactamente la diferencia entre una app de tareas y un sistema de dirección.
new a in_progress. Se registra action_started con actor, timestamp y transición.evidence_uploaded.in_review. El aprobador recibe notificación.approved y la acción habilita cierre. Si rechaza, evidencia rejected y acción vuelve a waiting_evidence.can_close_action() devuelve true. Sin evidencia aprobada, el botón cerrar está deshabilitado y muestra blocking_reasons.expected_score_recovery_min/max y se refleja en FARO Score.Cualquier acción presentada en esta UI tiene seis atributos no negociables, según FARO-UI-003 §2:
| Condición | Significado operativo |
|---|---|
| Comprensible | El responsable entiende qué debe hacer y por qué. |
| Ejecutable | Tiene pasos accionables y un criterio de cierre escrito. |
| Trazable | Tiene eventos y cambios registrados en execution_events. |
| Validable | Tiene evidencia requerida tipificada del catálogo EVD. |
| Auditable | Puede revisarse después con trazabilidad completa. |
| Medible | Tiene impacto Score esperado declarado. |
FARO-UI-002 (dashboard responsable) responde «¿qué tengo pendiente?». FARO-UI-003 responde «¿cómo cierro correctamente esta acción?». La primera es lista; la segunda es ejecución.
Los 10 estados son la verdad oficial del workflow FARO. Coinciden 1:1 con workflow-escalamiento-mvp.html (WF-001). Cualquier transición fuera de la matriz es rechazada por el backend con INVALID_STATUS_TRANSITION. Escalated es evento, no estado.
Acción creada por el motor FARO o por intervención manual. Todavía no la tomó nadie.
Botón: Iniciar acciónEl responsable inició la ejecución. Está trabajando en la acción y puede cargar evidencia o solicitar extensión.
Botón: Cargar evidenciaLa acción necesita evidencia para avanzar. Bloqueada hasta que se cargue la documentación requerida.
Botón: Cargar evidenciaEvidencia enviada al aprobador. Espera decisión: aprobar, rechazar o pedir más información.
Botón: Ver revisiónEvidencia aprobada, lista para cierre. can_close_action() devuelve true. Habilita botón cerrar.
Acción cerrada correctamente con evidencia aprobada. Genera evento action_closed e impacta FARO Score.
Imposibilidad de avance informada por el responsable. Requiere desbloqueo, escalamiento o cancelación.
Botón: Ver bloqueo / EscalarPasó la due_date sin cierre. Disparada por job batch (24h). Aún puede regularizarse, bloquearse o escalarse.
Acción o evidencia rechazada por el aprobador. Si era evidencia, la acción puede volver a waiting_evidence.
Acción cancelada con justificación por Gerente o Director. No impacta Score, queda auditada.
Botón: Ver cancelación14 transiciones canónicas con actor permitido. Coincide con workflow-escalamiento-mvp.html (WF-001).
| # | Desde | Hacia | Actor permitido | event_type emitido |
|---|---|---|---|---|
| T-01 | new | in_progress | responsible | action_started |
| T-02 | new | blocked | responsible | action_blocked |
| T-03 | new | cancelled | general_manager · director | action_cancelled |
| T-04 | in_progress | waiting_evidence | responsible · system | status_changed |
| T-05 | in_progress | blocked | responsible | action_blocked |
| T-06 | waiting_evidence | in_review | responsible | submitted_to_review |
| T-07 | in_review | approved | approver | evidence_approved |
| T-08 | in_review | rejected | approver | evidence_rejected |
| T-09 | rejected | waiting_evidence | responsible | status_changed |
| T-10 | approved | closed | general_manager · approver | action_closed |
| T-11 | blocked | in_progress | general_manager · responsible | action_unblocked |
| T-12 | expired | in_progress | general_manager · responsible | status_changed |
| T-13 | expired | blocked | responsible | action_blocked |
| T-14 | expired | in_review | general_manager · director | action_escalated + submitted_to_review |
D4 aplicado. «Escalated» no es estado; es evento. La acción permanece en blocked o in_review y se registra action_escalated con payload { escalated_to_role, reason }. Esta decisión evita inflar el enum de estados (que ya tiene 10) y separa el qué (estado del workflow) del cómo (eventos de gestión).
La regla más importante de toda la pieza: una acción se cierra solo cuando cumple su criterio y tiene evidencia válida. Esta regla la consume frontend, backend, motor evaluador, tests y reportes. Es la diferencia entre «FARO sistema de ejecución» y «FARO sistema de tareas pintado con paleta premium».
can_close_action()Una acción puede cerrarse solamente si las siete condiciones son verdaderas. Si alguna falla, el botón cerrar permanece deshabilitado y la UI muestra el array blocking_reasons retornado por la función.
| # | Condición | Validación |
|---|---|---|
| C-01 | Tiene criterio de cierre definido | actions.closure_criteria IS NOT NULL y no vacío. |
| C-02 | Tiene evidencia requerida cargada | Cada evidence_required_codes[] tiene al menos una evidencia submitted. |
| C-03 | La evidencia existe en catálogo | Cada evidence_code referenciado está en evidence_definitions. |
| C-04 | La evidencia está approved | Para cada requerida, hay al menos una evidencia con status='approved'. |
| C-05 | Si priority='critical', validación fuerte | Aprobador con rol general_manager o superior. |
| C-06 | No está bloqueada | actions.status <> 'blocked'. |
| C-07 | Usuario tiene permiso para cerrar | RLS + check de rol: aprobador autorizado, gerente o director. |
Hay un único atajo. Cuando dirección decide cerrar una acción sin evidencia formal (por ejemplo, una acción organizativa cuya evidencia "es" el resultado del próximo cierre mensual), se carga EVD-009. Es una evidencia tipificada del catálogo que:
general_manager o director).trust_level='low' y confidence_flag='manual_override' en el registro.action_closed_with_override en execution_events con payload { justification, override_role }.Regla dura MVP. Acción sin evidencia aprobada no se cierra. Si alguien quiere cerrarla igual, debe usar EVD-009 · Cierre manual justificado, y eso debe quedar auditado con menor confianza. No hay tercer camino. No hay flag force=true escondido. No hay endpoint admin que salte la validación.
Sin esta regla, FARO se convierte en una app de tareas con paleta sobria. Los responsables aprenden a marcar acciones como cerradas para limpiar su bandeja. Los reportes empiezan a mostrar «el 85 % de las acciones críticas están cerradas» aunque nada haya pasado. El motor Score recupera puntos basados en cierres ficticios. Las decisiones de dirección se toman sobre una realidad ajustada.
La regla dura es el contrato entre FARO y la dirección de la empresa: cuando FARO dice «cerrado», hubo evidencia revisada y aprobada. Esa es la diferencia operativa que justifica el producto.
Dos layouts simétricos: en desktop, columna principal con ejecución y columna lateral con contexto. En mobile, todo apilado y el botón principal cambia según estado para no obligar al usuario a buscar la acción correcta.
+------------------------------------------------------------+ | HEADER ACCIÓN | | ACT-COM-001 · Revisar política de descuentos | | Estado · Prioridad · Responsable · Vence | +------------------------------+-----------------------------+ | COLUMNA PRINCIPAL | COLUMNA LATERAL | | | | | Propósito ejecutivo | Tensión asociada | | Criterio de cierre | Evidencia requerida | | Evidencias cargadas | Aprobador | | Timeline | Score recovery | | | Riesgos / bloqueos | +------------------------------+-----------------------------+ | FOOTER STICKY | | Iniciar · Cargar evidencia · Revisión · Cerrar | +------------------------------------------------------------+
+--------------------------------+ | HEADER COMPACTO | | ACT-COM-001 | | Estado + Prioridad + Vence | +--------------------------------+ | Propósito (colapsable) | | Criterio de cierre | | Tensión asociada | | Evidencia requerida | | Evidencias cargadas | +--------------------------------+ | TIMELINE COLAPSABLE | +--------------------------------+ | BOTÓN PRINCIPAL CONTEXTUAL | | [Iniciar | Cargar | Cerrar] | | Acciones secundarias (kebab) | +--------------------------------+
En mobile no hay espacio para mostrar todos los comandos. La UI elige el botón principal según el estado actual de la acción.
| Estado | Botón principal | Acciones secundarias |
|---|---|---|
new | Iniciar acción | Bloquear · Cancelar |
in_progress | Cargar evidencia | Bloquear · Escalar · Extensión |
waiting_evidence | Cargar evidencia | Bloquear · Escalar |
in_review | Ver revisión | Comentar |
approved | Cerrar acción | Comentar |
blocked | Ver bloqueo / Escalar | Desbloquear · Cancelar |
expired | Regularizar | Extensión · Bloquear · Escalar |
rejected | Ver motivo | Volver a cargar evidencia |
closed | Ver cierre | Ver timeline · Ver impacto Score |
cancelled | Ver cancelación | Ver timeline |
Diseño visual. Fondo arena claro, header card blanca translúcida, pills sobrias, stepper azul petróleo, evidencia faltante ámbar sobrio, bloqueo rojo tenue, cierre aprobado verde sobrio, Score recovery azul petróleo + número destacado, footer sticky blanco blur, timeline con línea fina y puntos sobrios. Nada de confeti visual cuando se cierra una acción: esto no es una app de hábitos, es dirección empresaria.
GET /api/v1/actions/:idEl endpoint detalle es la fuente única que consume la UI. Devuelve la acción, responsable, aprobador, tensión asociada, requerimientos de evidencia, evidencia ya cargada, capacidades del workflow (con blocking_reasons) y el timeline cronológico. La UI no necesita llamar a ningún otro endpoint para renderizar el detalle completo.
{
"action": {
"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": "in_progress",
"priority": "critical",
"action_type": "corrective",
"due_date": "2026-06-06",
"is_overdue": false,
"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,
"created_at": "2026-05-30T09:03:00-03:00",
"updated_at": "2026-05-30T11:20:00-03:00"
},
"responsible": {
"user_id": "12000000-0000-0000-0000-000000000002",
"full_name": "María Fernández",
"email": "maria@empresa-demo-cuyo.com",
"role": "commercial_manager"
},
"approver": {
"user_id": "12000000-0000-0000-0000-000000000001",
"full_name": "Tomás Pombo",
"email": "direccion@empresa-demo-cuyo.com",
"role": "general_manager"
},
"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,
"business_question": "¿Estamos vendiendo más pero ganando menos?",
"executive_diagnosis": "La empresa crece en volumen, pero sacrifica rentabilidad."
},
"evidence_requirements": [
{
"evidence_code": "EVD-007",
"name": "Cambio de política",
"description": "Documento, registro o aprobación que acredita un cambio de política.",
"evidence_type": "document",
"trust_level": "critical",
"requires_review": true,
"can_close_action": true,
"status": "missing",
"submitted_count": 0,
"approved_count": 0
},
{
"evidence_code": "EVD-012",
"name": "Validación de dirección",
"description": "Validación formal de dirección registrada en sistema.",
"evidence_type": "approval",
"trust_level": "critical",
"requires_review": false,
"can_close_action": true,
"status": "missing",
"submitted_count": 0,
"approved_count": 0
}
],
"submitted_evidence": [],
"workflow": {
"can_start": false,
"can_upload_evidence": true,
"can_submit_review": false,
"can_approve": false,
"can_reject": false,
"can_close": false,
"can_block": true,
"can_escalate": true,
"can_request_extension": true,
"blocking_reasons": [
"Falta EVD-007 · Cambio de política",
"Falta EVD-012 · Validación de dirección"
]
},
"timeline": [
{
"event_id": "90000000-0000-0000-0000-000000000001",
"event_type": "action_created",
"title": "Acción creada por motor FARO",
"description": "La acción fue creada como recomendación de TNS-001.",
"actor": { "full_name": "Sistema FARO" },
"created_at": "2026-05-30T09:03:00-03:00"
},
{
"event_id": "90000000-0000-0000-0000-000000000002",
"event_type": "action_started",
"title": "Acción iniciada",
"description": "El responsable tomó la acción.",
"actor": { "full_name": "María Fernández" },
"created_at": "2026-05-30T11:20:00-03:00"
}
]
}
action: metadata canónica de la acción. title y description pueden venir del catálogo (action_definitions) si la instancia no los sobreescribió. is_overdue es calculado por la vista.responsible / approver: del JOIN con faro.users. role opcional para que la UI ajuste tono visual.tension: JOIN con faro.tensions y faro.tension_definitions para enriquecer con business_question y executive_diagnosis canónicos.evidence_requirements: agregación por evidence_code de la vista v_action_evidence_detail. Incluye status calculado: missing | submitted | approved | rejected | needs_more_info.submitted_evidence: lista cruda ordenada por submitted_at DESC.workflow: capacidades del usuario actual sobre esta acción. blocking_reasons viene de can_close_action().timeline: lista de eventos de execution_events filtrados por action_id, ordenados ASC.El detalle de acción no se ensambla desde el route handler con seis SELECT sueltos. Se ensambla desde dos vistas oficiales con JOINs prearmados: v_action_workflow_detail para acción + tensión + responsable + aprobador, y v_action_evidence_detail para evidencia requerida con estado agregado.
faro.v_action_workflow_detailJOIN entre actions, action_definitions, users (responsable y aprobador), tensions y tension_definitions. Calcula is_overdue y trae los campos canónicos enriquecidos para que el route handler arme el response sin más JOINs.
CREATE OR REPLACE VIEW faro.v_action_workflow_detail 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, 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.description AS tension_description, 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.trigger_logic, td.area_code, td.module_code, a.payload, a.created_at, a.updated_at FROM faro.actions a LEFT JOIN faro.action_definitions ad ON ad.action_code = a.action_code AND ad.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 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'; -- RLS: la vista hereda RLS de faro.actions vía company_id -- Index recomendado: faro.actions(company_id, action_id) ya existente
faro.v_action_evidence_detailPor cada evidence_required_codes[] definida en el catálogo de la acción, devuelve una fila con el estado agregado de la evidencia cargada (missing | submitted | approved | rejected). Es la base del checklist visual y del cálculo de can_close_action().
CREATE OR REPLACE VIEW faro.v_action_evidence_detail AS SELECT a.company_id, a.action_id, required.evidence_code, ed.name, ed.description, ed.evidence_type, ed.trust_level, ed.requires_review, ed.can_close_action, ed.allowed_submission_modes, ed.allowed_file_types, ed.required_metadata_keys, ed.validation_rules, COUNT(e.evidence_id) AS submitted_count, COUNT(e.evidence_id) FILTER ( WHERE e.status = 'approved' ) AS approved_count, COUNT(e.evidence_id) FILTER ( WHERE e.status = 'rejected' ) AS rejected_count, CASE WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'approved') > 0 THEN 'approved' WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'submitted') > 0 THEN 'submitted' WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'rejected') > 0 THEN 'rejected' ELSE 'missing' END AS evidence_status FROM faro.actions a LEFT JOIN faro.action_definitions ad ON ad.action_code = a.action_code AND ad.status = 'active' CROSS JOIN LATERAL unnest( COALESCE(ad.evidence_required_codes, ARRAY[]::text[]) ) AS required(evidence_code) LEFT JOIN faro.evidence_definitions ed ON ed.evidence_code = required.evidence_code AND ed.status = 'active' LEFT JOIN faro.evidence e ON e.company_id = a.company_id AND e.action_id = a.action_id AND e.evidence_code = required.evidence_code GROUP BY a.company_id, a.action_id, required.evidence_code, ed.name, ed.description, ed.evidence_type, ed.trust_level, ed.requires_review, ed.can_close_action, ed.allowed_submission_modes, ed.allowed_file_types, ed.required_metadata_keys, ed.validation_rules;
Decisión arquitectónica. Las dos vistas reemplazan código de aplicación. Si una vista necesita un campo nuevo (por ejemplo, last_approver_comment), se agrega ahí y todos los consumidores (UI, motor Score, reportes, exports) lo reciben sin recompilar nada. Las vistas también heredan RLS de faro.actions via company_id, asi que no hay forma de leer una acción de otra empresa.
Decisión D3 aplicada: una sola tabla execution_events guarda eventos de acción, evidencia, escalamiento, extensión y decisión. No hay action_events y evidence_events en paralelo. action_events queda como vista filtrada por event_category='action'. Este es el lugar oficial donde se define la tabla, no se duplica en otra pieza.
D3 · Una tabla, una verdad. Mantener tablas separadas para eventos de acción y de evidencia obliga a UNIONs en cada timeline y a duplicar índices, RLS y políticas. Una tabla única con event_category + event_type mantiene un solo timeline auditable, una sola política RLS, un solo set de índices y un solo enum mantenido. Las consultas por categoría se cubren con índice parcial.
El CHECK constraint sobre event_type garantiza que no entre nada fuera del catálogo. Agregar un nuevo tipo requiere migración explícita, no se hace en runtime.
| Categoría | event_type | Cuándo se emite |
|---|---|---|
| Acción (workflow) | action_created | Motor crea la acción a partir de una tensión. |
action_started | Responsable inicia ejecución (new → in_progress). | |
status_changed | Transición de estado genérica no cubierta por otros eventos. | |
action_blocked | Responsable marca acción como bloqueada con motivo. | |
action_unblocked | Gerente o responsable desbloquea acción. | |
action_escalated | Acción elevada a rol superior (D4: evento, no estado). | |
action_closed | Acción cerrada con evidencia aprobada. | |
action_closed_with_override | Cierre manual justificado vía EVD-009. | |
action_cancelled | Acción cancelada con justificación por gerente/director. | |
action_reassigned | Cambio de responsable o aprobador. | |
| Evidencia | evidence_uploaded | Responsable subió un documento, captura o comentario. |
submitted_to_review | Evidencia enviada al aprobador (waiting_evidence → in_review). | |
evidence_approved | Aprobador valida la evidencia. | |
evidence_rejected | Aprobador rechaza con motivo. Evidencia queda como histórico. | |
evidence_needs_more_info | Aprobador pide ampliación al responsable. | |
evidence_replaced | Nueva versión de una evidencia previamente rechazada. | |
evidence_archived | Evidencia retirada por motivos de governance (sin borrar). | |
evidence_metadata_updated | Cambio de metadata sin reemplazar archivo. | |
| Extensión | extension_requested | Responsable pide nueva fecha de vencimiento con motivo. |
extension_approved | Gerente o director aprueba extensión. | |
extension_rejected | Extensión rechazada con motivo. | |
due_date_changed | Cambio de fecha resultante de extensión aprobada. | |
extension_expired | Solicitud de extensión vencida sin respuesta. | |
| Escalamiento | escalation_triggered | Sistema detecta condición de escalamiento automático. |
escalation_assigned | Acción asignada a rol superior por escalamiento. | |
escalation_acknowledged | Rol escalado confirma recepción. | |
escalation_resolved | Escalamiento cerrado con decisión. | |
| Comentarios y comunicación | comment_added | Comentario en hilo de acción (visible a involucrados). |
internal_note_added | Nota interna visible solo para dirección/auditor. | |
notification_sent | Notificación enviada (email/in-app/webhook). | |
reminder_sent | Recordatorio automático de SLA. | |
mention_added | Mención a usuario en comentario. | |
| Decisión e impacto | decision_logged | Decisión vinculada a la acción registrada en bitácora. |
score_recovery_applied | Recuperación de Score aplicada tras cierre exitoso. | |
score_recovery_reversed | Reversión de recuperación por re-evaluación. | |
kpi_recalculated | KPIs recalculados por cierre de acción. | |
| Auditoría técnica | permission_check_failed | Intento de comando sin permiso (queda en log). |
invalid_transition_attempted | Intento de transición no permitida por workflow. | |
integration_synced | Sincronización con sistema externo (ERP, CRM). | |
data_quality_flag_raised | Bandera de calidad de dato disparada por el motor. |
CREATE TABLE IF NOT EXISTS faro.execution_events ( execution_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, -- contexto: al menos uno de action_id, evidence_id, decision_id debe estar action_id uuid NULL, evidence_id uuid NULL, decision_id uuid NULL, tension_id uuid NULL, event_category text NOT NULL CHECK ( event_category IN ( 'action', 'evidence', 'extension', 'escalation', 'comment', 'decision', 'audit' ) ), event_type text NOT NULL CHECK ( event_type IN ( -- action workflow (10) 'action_created', 'action_started', 'status_changed', 'action_blocked', 'action_unblocked', 'action_escalated', 'action_closed', 'action_closed_with_override', 'action_cancelled', 'action_reassigned', -- evidence (8) 'evidence_uploaded', 'submitted_to_review', 'evidence_approved', 'evidence_rejected', 'evidence_needs_more_info', 'evidence_replaced', 'evidence_archived', 'evidence_metadata_updated', -- extension (5) 'extension_requested', 'extension_approved', 'extension_rejected', 'due_date_changed', 'extension_expired', -- escalation (4) 'escalation_triggered', 'escalation_assigned', 'escalation_acknowledged', 'escalation_resolved', -- comment / comms (5) 'comment_added', 'internal_note_added', 'notification_sent', 'reminder_sent', 'mention_added', -- decision / impact (4) 'decision_logged', 'score_recovery_applied', 'score_recovery_reversed', 'kpi_recalculated', -- audit (4) 'permission_check_failed', 'invalid_transition_attempted', 'integration_synced', 'data_quality_flag_raised' ) ), actor_user_id uuid NULL, actor_role text NULL, actor_kind text NOT NULL DEFAULT 'human' CHECK (actor_kind IN ('human', 'system', 'integration', 'webhook')), from_status text NULL, to_status text NULL, title text NOT NULL, description text NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, occurred_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), CHECK ( action_id IS NOT NULL OR evidence_id IS NOT NULL OR decision_id IS NOT NULL ) ); CREATE INDEX IF NOT EXISTS idx_exec_events_company_action ON faro.execution_events(company_id, action_id, occurred_at DESC) WHERE action_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_exec_events_company_evidence ON faro.execution_events(company_id, evidence_id, occurred_at DESC) WHERE evidence_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_exec_events_category_type ON faro.execution_events(event_category, event_type); CREATE INDEX IF NOT EXISTS idx_exec_events_actor ON faro.execution_events(actor_user_id, occurred_at DESC); -- RLS por company_id ALTER TABLE faro.execution_events ENABLE ROW LEVEL SECURITY; CREATE POLICY execution_events_rls ON faro.execution_events USING (company_id::text = current_setting('app.company_id', true)); -- Vista compatibilidad: action_events queda como filtro CREATE OR REPLACE VIEW faro.action_events AS SELECT * FROM faro.execution_events WHERE event_category = 'action' OR action_id IS NOT NULL;
Compatibilidad sin duplicación. faro.action_events ya no es tabla; es vista sobre execution_events filtrada por action_id IS NOT NULL. Todo código legacy que la consultaba sigue funcionando, pero la verdad única vive en execution_events. Migraciones de datos: si existía una tabla previa, migrar con INSERT INTO faro.execution_events SELECT ... FROM faro.action_events_legacy + DROP TABLE.
blocking_reasonsLa fuente única de verdad para "¿puedo cerrar esta acción?" es una función PL/pgSQL. Devuelve (can_close boolean, blocking_reasons text[]). La consume el route handler para responder al frontend, el endpoint close para validar antes de actualizar, y los tests para verificar la regla dura. No hay lógica de cierre duplicada en TS o en motor evaluador.
CREATE OR REPLACE FUNCTION faro.can_close_action(p_action_id uuid) RETURNS TABLE ( can_close boolean, blocking_reasons text[] ) AS $$ DECLARE v_missing_count integer; v_action_status text; v_action_priority text; v_closure_criteria text; v_evidence_count integer; v_reasons text[] := ARRAY[]::text[]; BEGIN -- 1. Leer estado actual de la acción SELECT status, priority, closure_criteria INTO v_action_status, v_action_priority, v_closure_criteria FROM faro.actions WHERE action_id = p_action_id; IF v_action_status IS NULL THEN RETURN QUERY SELECT false, ARRAY['Acción inexistente']::text[]; RETURN; END IF; -- 2. Estados terminales no se reabren IF v_action_status IN ('closed', 'cancelled', 'rejected') THEN RETURN QUERY SELECT false, ARRAY['La acción ya no está abierta']::text[]; RETURN; END IF; -- 3. Bloqueada no se cierra IF v_action_status = 'blocked' THEN v_reasons := array_append(v_reasons, 'La acción está bloqueada'); END IF; -- 4. Necesita criterio de cierre IF v_closure_criteria IS NULL OR trim(v_closure_criteria) = '' THEN v_reasons := array_append(v_reasons, 'La acción no tiene criterio de cierre'); END IF; -- 5. Cuenta de evidencia requerida SELECT COUNT(*) INTO v_evidence_count FROM faro.v_action_evidence_detail WHERE action_id = p_action_id; IF v_evidence_count = 0 THEN v_reasons := array_append(v_reasons, 'La acción no tiene evidencia requerida configurada'); END IF; -- 6. Evidencias requeridas con estado distinto de approved SELECT COUNT(*) INTO v_missing_count FROM faro.v_action_evidence_detail WHERE action_id = p_action_id AND evidence_status <> 'approved'; IF v_missing_count > 0 THEN FOR r IN SELECT evidence_code, name FROM faro.v_action_evidence_detail WHERE action_id = p_action_id AND evidence_status <> 'approved' ORDER BY evidence_code LOOP v_reasons := array_append( v_reasons, format('Falta %s · %s', r.evidence_code, r.name) ); END LOOP; END IF; -- 7. Si priority='critical', requiere aprobador con rol senior IF v_action_priority = 'critical' THEN IF NOT EXISTS ( SELECT 1 FROM faro.actions a JOIN faro.users u ON u.user_id = a.approver_user_id WHERE a.action_id = p_action_id AND u.role IN ('general_manager', 'director') ) THEN v_reasons := array_append( v_reasons, 'Acción crítica requiere aprobador general_manager o director' ); END IF; END IF; RETURN QUERY SELECT cardinality(v_reasons) = 0, v_reasons; END; $$ LANGUAGE plpgsql STABLE; -- Test rápido en la demo: -- SELECT * FROM faro.can_close_action('23000000-0000-0000-0000-000000000001'); -- Esperado para Empresa Demo Cuyo S.A.: -- can_close = false -- blocking_reasons = {'Falta EVD-007 · Cambio de política', -- 'Falta EVD-012 · Validación de dirección'}
workflow.can_close y workflow.blocking_reasons.close: la llama dentro de la transacción, antes del UPDATE. Si can_close=false responde 409 con los motivos.ActionClosureCriteria: muestra los motivos al usuario para que sepa qué falta.blocking_reasons cambian, dispara recordatorios al responsable.Un endpoint para leer detalle completo y diez endpoints de comando, uno por transición operativa. Todos validan sesión, seteán RLS, bloquean fila con FOR UPDATE, validan transición, ejecutan UPDATE, insertan evento y commitean. Las firmas son simétricas para que el cliente sea trivial de mantener.
new → in_progress). Emite action_started.
evidence_uploaded.
in_review.
{ evidence_id, comment? }.
{ evidence_id, reason } obligatorio.
{ reason, suggested_owner? }.
{ escalated_to_role, reason }.
{ requested_due_date, reason }.
can_close_action(). 409 si falla, 200 si OK.
| HTTP | Body | Cuándo |
|---|---|---|
200 | { ok: true } o response detallado | Comando exitoso. |
400 | { error, details } | Validación fallida (campo faltante, formato incorrecto). |
401 | { error: "Unauthorized" } | Sin sesión válida. |
403 | { error: "Forbidden" } | Sesión OK pero sin permiso para el comando. |
404 | { error: "Action not found" } | Acción no existe en la company_id de la sesión. |
409 | { error, blocking_reasons[] } | Conflicto de estado: INVALID_STATUS_TRANSITION o ACTION_CANNOT_BE_CLOSED. |
500 | { error } | Error inesperado (transacción revertida). |
Stack canónico: Next.js + React + TypeScript + Tailwind. Tipos en lib/faro/action-workflow.types.ts, componentes en components/action-workflow/*, API client en lib/faro/action-workflow.api.ts, route handlers en app/api/v1/actions/[id]/* con seteo de RLS por sesión.
export type ActionWorkflowStatus = | "new" | "in_progress" | "waiting_evidence" | "in_review" | "approved" | "closed" | "blocked" | "expired" | "rejected" | "cancelled"; export type ActionWorkflowPriority = | "low" | "medium" | "high" | "critical"; export type EvidenceStatus = | "missing" | "submitted" | "approved" | "rejected" | "needs_more_info"; export type EvidenceRequirementDetail = { evidence_code: string; name: string; description: string | null; evidence_type: string; trust_level: "low" | "medium" | "high" | "critical"; requires_review: boolean; can_close_action: boolean; status: EvidenceStatus; submitted_count: number; approved_count: number; }; export type SubmittedEvidence = { evidence_id: string; evidence_code: string; title: string; description: string | null; status: "submitted" | "approved" | "rejected" | "needs_more_info"; submitted_by: string | null; submitted_at: string; reviewed_by: string | null; reviewed_at: string | null; review_comment: string | null; }; export type ActionTimelineEvent = { event_id: string; event_type: string; title: string; description: string | null; actor: { full_name: string | null }; created_at: string; }; export type ActionWorkflowDetailResponse = { action: { action_id: string; action_code: string; title: string; description: string | null; status: ActionWorkflowStatus; priority: ActionWorkflowPriority; action_type: string; due_date: string | null; is_overdue: boolean; closure_criteria: string | null; expected_impact: string | null; expected_score_recovery_min: number | null; expected_score_recovery_max: number | null; created_at: string; updated_at: string; }; responsible: { user_id: string | null; full_name: string | null; email: string | null; role?: string | null; }; approver: { user_id: string | null; full_name: string | null; email: string | null; role?: string | 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_requirements: EvidenceRequirementDetail[]; submitted_evidence: SubmittedEvidence[]; workflow: { can_start: boolean; can_upload_evidence: boolean; can_submit_review: boolean; can_approve: boolean; can_reject: boolean; can_close: boolean; can_block: boolean; can_escalate: boolean; can_request_extension: boolean; blocking_reasons: string[]; }; timeline: ActionTimelineEvent[]; };
Cada componente tiene una responsabilidad acotada y consume solo el slice que necesita del response.
blocking_reasons si can_close=false.workflow.*.import { ActionWorkflowPage } from "@/components/action-workflow/ActionWorkflowPage"; export default function Page({ params }: { params: { id: string } }) { return <ActionWorkflowPage actionId={params.id} />; }
"use client"; import { useEffect, useState } from "react"; import { getActionWorkflowDetail } from "@/lib/faro/action-workflow.api"; import type { ActionWorkflowDetailResponse } from "@/lib/faro/action-workflow.types"; import { ActionWorkflowHeader } from "./ActionWorkflowHeader"; import { ActionStatusStepper } from "./ActionStatusStepper"; import { ActionPurposeBlock } from "./ActionPurposeBlock"; import { ActionClosureCriteria } from "./ActionClosureCriteria"; import { LinkedTensionSummary } from "./LinkedTensionSummary"; import { EvidenceRequirementChecklist } from "./EvidenceRequirementChecklist"; import { EvidenceSubmittedList } from "./EvidenceSubmittedList"; import { ActionTimeline } from "./ActionTimeline"; import { ActionScoreImpact } from "./ActionScoreImpact"; import { ActionWorkflowFooter } from "./ActionWorkflowFooter"; export function ActionWorkflowPage({ actionId }: { actionId: string }) { const [data, setData] = useState<ActionWorkflowDetailResponse | null>(null); const [loading, setLoading] = useState(true); async function reload() { const result = await getActionWorkflowDetail(actionId); setData(result); } useEffect(() => { let cancelled = false; (async () => { setLoading(true); try { const result = await getActionWorkflowDetail(actionId); if (!cancelled) setData(result); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [actionId]); if (loading && !data) return <LoadingState />; if (!data) return <ErrorState />; return ( <main className="min-h-screen bg-[#F7FAFF] px-4 py-5 text-[#071A44] md:px-6 md:py-6"> <div className="mx-auto flex max-w-7xl flex-col gap-5"> <ActionWorkflowHeader data={data} /> <ActionStatusStepper status={data.action.status} /> <section className="grid gap-5 xl:grid-cols-[1.2fr_.8fr]"> <div className="space-y-5"> <ActionPurposeBlock data={data} /> <ActionClosureCriteria data={data} /> <EvidenceSubmittedList evidence={data.submitted_evidence} /> <ActionTimeline events={data.timeline} /> </div> <aside className="space-y-5"> <LinkedTensionSummary tension={data.tension} /> <EvidenceRequirementChecklist requirements={data.evidence_requirements} /> <ActionScoreImpact action={data.action} /> </aside> </section> <ActionWorkflowFooter data={data} onChanged={reload} /> </div> </main> ); }
El endpoint GET ensambla el response a partir de las vistas y la función. Seteá RLS via set_config('app.company_id', ...) al inicio de la transacción.
import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/server/db"; import { getSessionContext } from "@/lib/server/session"; export async function GET( _request: NextRequest, { params }: { params: { id: string } } ) { const session = await getSessionContext(); if (!session?.companyId || !session?.userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const client = await db.connect(); try { await client.query("BEGIN"); // Setear RLS por sesión 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(",")]); // Detalle desde la vista const actionResult = await client.query( `SELECT * FROM faro.v_action_workflow_detail WHERE company_id = $1 AND action_id = $2 LIMIT 1`, [session.companyId, params.id] ); if (actionResult.rows.length === 0) { await client.query("ROLLBACK"); return NextResponse.json({ error: "Action not found" }, { status: 404 }); } const action = actionResult.rows[0]; const evidenceRequirements = await client.query( `SELECT * FROM faro.v_action_evidence_detail WHERE company_id = $1 AND action_id = $2 ORDER BY evidence_code`, [session.companyId, params.id] ); const submittedEvidence = await client.query( `SELECT e.*, su.full_name AS submitted_by_name, ru.full_name AS reviewed_by_name FROM faro.evidence e LEFT JOIN faro.users su ON su.user_id = e.submitted_by LEFT JOIN faro.users ru ON ru.user_id = e.reviewed_by WHERE e.company_id = $1 AND e.action_id = $2 ORDER BY e.submitted_at DESC`, [session.companyId, params.id] ); const timeline = await client.query( `SELECT ee.execution_event_id AS event_id, ee.event_type, ee.title, ee.description, u.full_name AS actor_name, ee.occurred_at AS created_at FROM faro.execution_events ee LEFT JOIN faro.users u ON u.user_id = ee.actor_user_id WHERE ee.company_id = $1 AND ee.action_id = $2 ORDER BY ee.occurred_at ASC`, [session.companyId, params.id] ); const closeCheck = await client.query( `SELECT * FROM faro.can_close_action($1)`, [params.id] ); await client.query("COMMIT"); const canClose = closeCheck.rows[0]?.can_close ?? false; const blockingReasons = closeCheck.rows[0]?.blocking_reasons ?? []; return NextResponse.json({ action: mapAction(action), responsible: mapUser(action, "responsible"), approver: mapUser(action, "approver"), tension: mapTension(action), evidence_requirements: evidenceRequirements.rows.map(mapEvidenceRequirement), submitted_evidence: submittedEvidence.rows.map(mapSubmittedEvidence), workflow: buildWorkflow(action, canClose, blockingReasons, session), timeline: timeline.rows.map(mapTimelineEvent) }); } catch (error) { await client.query("ROLLBACK"); return NextResponse.json({ error: "Could not load action detail" }, { status: 500 }); } finally { client.release(); } } function buildWorkflow(action: any, canClose: boolean, blockingReasons: string[], session: any) { const isResponsible = action.responsible_user_id === session.userId; const isApprover = action.approver_user_id === session.userId; const isManager = session.roleCodes.includes("general_manager") || session.roleCodes.includes("director"); return { can_start: isResponsible && action.status === "new", can_upload_evidence: isResponsible && ["new", "in_progress", "waiting_evidence", "expired"].includes(action.status), can_submit_review: isResponsible && ["waiting_evidence", "in_progress", "expired"].includes(action.status), can_approve: (isApprover || isManager) && action.status === "in_review", can_reject: (isApprover || isManager) && action.status === "in_review", can_close: canClose && (isApprover || isManager), can_block: isResponsible && !["closed", "cancelled", "rejected"].includes(action.status), can_escalate: isResponsible || isManager, can_request_extension: isResponsible && !["closed", "cancelled", "rejected"].includes(action.status), blocking_reasons: blockingReasons }; }
Patrón obligatorio para todos los comandos. Cada endpoint POST sigue el mismo esqueleto: BEGIN → setear RLS → SELECT ... FOR UPDATE de la fila → validar estado actual → validar transición permitida → validar permisos → UPDATE → INSERT execution_events → COMMIT. Sin FOR UPDATE, dos usuarios podrían cerrar la misma acción al mismo tiempo y ahí empieza la fiesta de inconsistencias.
Mockup estático del detalle de acción ACT-COM-001 sobre la tensión TNS-001 «Crecimiento no rentable» en Empresa Demo Cuyo S.A. Acción en estado in_progress, evidencia todavía no cargada, botón cerrar deshabilitado con motivos visibles. Renderizado sin frameworks, solo HTML + CSS del pack.
Analizar descuentos aplicados por vendedor, producto, cliente y sucursal. Definir nueva política y comunicarla al equipo comercial.
Lectura del mockup. La pantalla muestra la acción ACT-COM-001 en in_progress. Tres elementos visuales comunican la regla dura: (1) el botón «Cerrar acción» aparece pero deshabilitado, (2) la card de criterio de cierre muestra los dos blocking_reasons en ámbar, (3) la columna lateral lista las dos evidencias requeridas como missing con badge coral. El usuario no necesita leer documentación para saber qué le falta.
FARO-UI-003 se acepta solo si cumple los tres bloques: funcional (qué tiene que mostrar y permitir), técnico (cómo lo construye), y rechazo (qué condiciones disparan rebuild). Tests mínimos cubren UI, API y la regla dura.
blocking_reasons.execution_events.can_close=true.GET /api/v1/actions/:id contra v_action_workflow_detail.set_config('app.company_id', ...) en cada transacción.SELECT ... FOR UPDATE en cada comando que modifica estado.execution_events por cada cambio.audit.audit_log en paralelo.faro.can_close_action() como única fuente de verdad de cierre.status='rejected'.event_type canónicos sin alterar el enum.execution_events.FOR UPDATE en endpoint close.can_close=false.| Capa | Test | Esperado |
|---|---|---|
| UI | Render acción new | Muestra botón «Iniciar acción» primario. |
Render acción in_progress | Muestra botón «Cargar evidencia» primario. | |
| Render sin evidencia aprobada | Botón «Cerrar» deshabilitado + lista de motivos. | |
| Render con evidencia aprobada | Botón «Cerrar» habilitado. | |
| Render acción vencida | Muestra pill ámbar «Vencida». | |
| Render timeline vacío | Empty state «Sin eventos registrados». | |
| API | GET detalle de acción existente | 200 con estructura completa. |
GET de otra company_id | 404 (RLS filtra). | |
POST start sobre in_progress | 409 INVALID_STATUS_TRANSITION. | |
| POST close sin evidencia | 409 ACTION_CANNOT_BE_CLOSED con blocking_reasons. | |
| POST close con evidencia aprobada | 200 + evento action_closed en execution_events. | |
| POST escalate sin reason | 400 «reason is required». | |
| SQL | can_close_action() sin evidencia | can_close=false, blocking_reasons con dos motivos. |
can_close_action() con evidencia aprobada | can_close=true, blocking_reasons=[]. | |
INSERT en execution_events con event_type inválido | Falla por CHECK constraint. |
import { describe, expect, it } from "vitest"; import { withTestDbContext } from "../src/helpers/dbTestContext"; describe("FARO-UI-003 · Regla dura de cierre", () => { it("no cierra acción sin evidencia aprobada", async () => { await withTestDbContext( { companyId: "10000000-0000-0000-0000-000000000001", // Empresa Demo Cuyo S.A. userId: "12000000-0000-0000-0000-000000000001", // Tomás Pombo (general_manager) roleCodes: ["general_manager"] }, async (client) => { const result = await client.query( `SELECT * FROM faro.can_close_action($1)`, ["23000000-0000-0000-0000-000000000001"] // ACT-COM-001 ); expect(result.rows[0].can_close).toBe(false); expect(result.rows[0].blocking_reasons.length).toBeGreaterThan(0); expect(result.rows[0].blocking_reasons).toContain("Falta EVD-007 · Cambio de política"); expect(result.rows[0].blocking_reasons).toContain("Falta EVD-012 · Validación de dirección"); } ); }); it("genera evento action_closed al cerrar con evidencia", async () => { // 1. Cargar y aprobar EVD-007 y EVD-012 sobre ACT-COM-001 // 2. POST /api/v1/actions/:id/close // 3. Verificar response 200 // 4. Verificar que existe evento execution_events con event_type='action_closed' }); });
FARO-UI-003 no vive sola. Consume catálogos, alimenta dashboards, dispara cambios de Score y coordina con la pieza de workflow y escalamiento.
Estados oficiales (WF-001), matriz de transiciones y reglas de escalamiento. Verdad única que debe coincidir con esta pieza.
Bandeja del responsable (FARO-UI-002). El usuario hace clic en una acción ahí y aterriza en esta pantalla.
Pieza FARO-UI-004 (carga de evidencia + validación). Se abre como modal o pantalla desde esta UI.
Pieza FARO-UI-005 (timeline de ejecución + auditoría). Vista extendida del timeline embebido acá.
Catálogo canónico MVP de acciones. action_definitions con evidence_required_codes[] que alimenta la vista.
Catálogo canónico MVP de evidencias. evidence_definitions con trust_level y can_close_action. Incluye EVD-009.
Catálogo canónico MVP de tensiones. tension_definitions con business_question y executive_diagnosis que enriquecen la card.
Motor FARO Score. Al cerrar la acción se dispara score_recovery_applied con el delta calculado.
DDL completo. Vistas v_action_workflow_detail, v_action_evidence_detail, tabla execution_events y función can_close_action() viven acá.
10 estados, 40 event_types, 11 endpoints, 16 componentes, 2 vistas SQL y 1 función. La regla dura sigue siendo la misma: sin evidencia aprobada no se cierra. Pasá al hub para ver el resto del pack o seguí con la pieza de workflow y escalamiento.
→ Volver al hub modelos NDA