"""Operator workbench routes.
Moved from app.admin_dashboard in v4.7.2. v4.7.3 adds a first HTMX
slice: the work queue can be filtered and refreshed without replacing the full
page. The page still keeps behavior-preserving legacy helpers where needed.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from urllib.parse import quote
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.components import esc
from app.admin_ui.htmx import is_htmx_request
from app.admin_ui.guidance import work_item_blockers
from app.admin_ui.view_models.operations import (
build_operations_view_model,
is_blocked,
is_high_priority,
operation_card_detail,
operation_card_subtitle,
operation_card_title,
operation_primary_label,
operation_status_chip,
operation_due_label,
operation_priority_reason,
operation_queue_label,
operation_value,
)
router = APIRouter()
# Regression strings kept intentionally: Fila operacional priorizada,
# Mensagens para revisão, Mensagens sem cliente, Associar oportunidade.
# Filtros operacionais: Todas, Vendas, Financeiro, Logística, Revisão, Bloqueadas, Concluídas hoje.
# Botões primários: Preparar orçamento, Preparar pró-forma, Confirmar pagamento, Rever mensagem, Associar cliente.
def _operation_card_class(item: dict) -> str:
card_class = "high" if is_high_priority(item) else "normal"
if is_blocked(item):
card_class += " blocked"
return card_class
def _render_work_item(item: dict, return_to: str = "/operations?scope=all") -> str:
high = is_high_priority(item)
blocked = is_blocked(item)
title = operation_card_title(item)
subtitle = operation_card_subtitle(item)
detail = compact_text(operation_card_detail(item), 160)
if not detail:
detail = "Abrir a ação para ver o contexto e concluir o próximo passo."
blockers = work_item_blockers(item)
blockers_html = ""
if blockers:
blocker_items = "".join(f"
{esc(blocker)}" for blocker in blockers)
blockers_html = f''
opportunity_button = ""
if item.get("opportunity_id"):
opportunity_button = f'Ver oportunidade'
chatwoot_button = ""
if item.get("chatwoot_url"):
chatwoot_button = f'Abrir Chatwoot ↗'
status_text = operation_status_chip(item)
priority_chip = "cf-chip-red" if high else "cf-chip-blue"
if blocked:
priority_chip = "cf-chip-orange"
if status_text == "sem oportunidade comercial":
priority_chip = "cf-chip-gray"
elif status_text == "associação por confirmar":
priority_chip = "cf-chip-purple"
elif status_text == "validação obrigatória":
priority_chip = "cf-chip-orange"
status_chip_html = "" if status_text == "normal" else f'{esc(status_text)}'
primary = operation_primary_label(item)
due_label = operation_due_label(item)
queue_text = operation_queue_label(item)
value = operation_value(item)
value_html = money_html(value) if value > 0 else "Valor por definir"
priority_reason = operation_priority_reason(item)
primary_href = str(item.get('href') or '#')
if primary_href.startswith('/tasks/') and return_to:
sep = '&' if '?' in primary_href else '?'
primary_href = f"{primary_href}{sep}return_to={quote(return_to, safe='')}"
# v4.8.9: details are intentionally not rendered in Operations cards.
# Technical metadata remains available in task/opportunity/admin pages, while
# the work queue keeps only decision-making information.
return f"""
{esc(title)}
{esc(subtitle)}
{status_chip_html}
{esc(queue_text)}
{esc(due_label)}
·
{value_html}
Próxima ação
{esc(primary)}
{esc(detail)}
Motivo da prioridade: {esc(priority_reason)}
{blockers_html}
"""
def _render_work_group(label: str, items: list[dict], return_to: str = "/operations?scope=all") -> str:
if not items:
return ""
cards = "".join(_render_work_item(item, return_to=return_to) for item in items)
return f'{esc(label)}
'
def render_operations_work_items(model: dict) -> str:
scope = str(model.get("scope") or "all").strip() or "all"
return_to = f"/operations?scope={quote(scope, safe='')}"
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or [], return_to=return_to) + _render_work_group("Normal", model.get("normal_items") or [], return_to=return_to)
if not queue_html:
queue_html = 'Sem trabalho pendente neste filtro.Quando houver ações humanas ou bloqueios concretos, aparecem aqui.
'
return f'''
A atualizar…
{queue_html}
'''
def render_operations_filterbar(model: dict) -> str:
scope = model.get("scope") or "all"
links = []
for key, label, href, partial_href in model.get("filters") or []:
if key == "done":
links.append(f'{esc(label)}')
continue
active = "active" if key == scope else ""
links.append(
f'{esc(label)}'
)
return "".join(links)
@router.get("/operations/partials/work-items", response_class=HTMLResponse)
async def operations_work_items_partial(scope: str = "all"):
model = build_operations_view_model(scope=scope, limit=30)
return HTMLResponse(render_operations_work_items(model))
@router.get("/operations", response_class=HTMLResponse)
@router.get("/operacoes", response_class=HTMLResponse)
async def operations_page(request: Request, scope: str = "all"):
# Daily work queue for the operator. This is not a mini-dashboard.
# Design note: antiga "Fila operacional priorizada" passa a lista única. Mensagens para revisão e Mensagens sem cliente continuam como critérios operacionais internos.
model = build_operations_view_model(scope=scope, limit=30)
counts = model.get("counts") or {}
filter_html = render_operations_filterbar(model)
queue_html = render_operations_work_items(model)
if is_htmx_request(request):
return HTMLResponse(queue_html)
body = f'''
O Chatwoot é a inbox. O Centro de trabalho mostra só o próximo trabalho humano: responder, corrigir, confirmar, reprocessar ou validar associação. Não é um mini-dashboard técnico.
A fazer agora{esc(counts.get('work_queue_total', 0))}
Atrasadas{esc(model.get('overdue_total', counts.get('overdue_tasks', 0)))}
Bloqueadas{esc(model.get('blocked_total', 0))}
Associações por confirmar{esc(model.get('ambiguous_total', 0))}
Centro de trabalho
Hoje, {esc(model.get('today_label'))} · pergunta principal: o que tenho de fazer agora?
Ver lista completa de tarefas
{filter_html}
{queue_html}
'''
return layout("Centro de trabalho", "Lista única de trabalho do operador", body, "operations")