"""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'
    Bloqueio atual
    ' 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}
    {esc(primary)}
    {chatwoot_button} {opportunity_button}
    """ 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)}
    {cards}
    ' 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")