213 lines
9.8 KiB
Python
213 lines
9.8 KiB
Python
"""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"<li>{esc(blocker)}</li>" for blocker in blockers)
|
|
blockers_html = f'<div class="cf-work-blockers"><strong>Bloqueio atual</strong><ul>{blocker_items}</ul></div>'
|
|
|
|
opportunity_button = ""
|
|
if item.get("opportunity_id"):
|
|
opportunity_button = f'<a class="btn btn-sm btn-outline-secondary" href="/opportunities/{esc(item.get("opportunity_id"))}">Ver oportunidade</a>'
|
|
chatwoot_button = ""
|
|
if item.get("chatwoot_url"):
|
|
chatwoot_button = f'<a class="btn btn-sm btn-outline-primary" href="{esc(item.get("chatwoot_url"))}" target="_blank" rel="noopener">Abrir Chatwoot ↗</a>'
|
|
|
|
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'<span class="cf-chip {priority_chip}">{esc(status_text)}</span>'
|
|
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"""
|
|
<article class="cf-work-card {esc(_operation_card_class(item))}">
|
|
<div class="cf-work-card-head">
|
|
<div>
|
|
<div class="cf-work-client">{esc(title)}</div>
|
|
<div class="cf-work-process">{esc(subtitle)}</div>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-1 justify-content-end">
|
|
{status_chip_html}
|
|
</div>
|
|
</div>
|
|
<div class="cf-work-meta d-flex flex-wrap gap-2 small text-secondary mb-2">
|
|
<span class="badge text-bg-light border">{esc(queue_text)}</span>
|
|
<span>{esc(due_label)}</span>
|
|
<span>·</span>
|
|
<span>{value_html}</span>
|
|
</div>
|
|
<div class="cf-work-next compact">
|
|
<span>Próxima ação</span>
|
|
<strong>{esc(primary)}</strong>
|
|
<div class="small text-secondary mt-1 text-break">{esc(detail)}</div>
|
|
<div class="small mt-2"><strong>Motivo da prioridade:</strong> {esc(priority_reason)}</div>
|
|
</div>
|
|
{blockers_html}
|
|
<div class="cf-work-actions">
|
|
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(primary_href)}">{esc(primary)}</a>
|
|
<div class="cf-work-secondary-actions">
|
|
{chatwoot_button}
|
|
{opportunity_button}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
"""
|
|
|
|
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'<div class="cf-work-section-title">{esc(label)}</div><section class="cf-work-section">{cards}</section>'
|
|
|
|
|
|
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 = '<div class="cf-work-empty"><strong>Sem trabalho pendente neste filtro.</strong><div class="small mt-1">Quando houver ações humanas ou bloqueios concretos, aparecem aqui.</div></div>'
|
|
return f'''
|
|
<div id="operations-work-items" class="cf-live-panel" aria-live="polite">
|
|
<div class="d-flex justify-content-end mb-2">
|
|
<span class="small text-secondary htmx-indicator" id="operations-loading">A atualizar…</span>
|
|
</div>
|
|
{queue_html}
|
|
</div>
|
|
'''
|
|
|
|
|
|
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'<a href="{esc(href)}">{esc(label)}</a>')
|
|
continue
|
|
active = "active" if key == scope else ""
|
|
links.append(
|
|
f'<a href="{esc(href)}" class="{active}" '
|
|
f'hx-get="{esc(partial_href)}" hx-target="#operations-work-items" '
|
|
f'hx-swap="outerHTML" hx-push-url="{esc(href)}" '
|
|
f'hx-indicator="#operations-loading">{esc(label)}</a>'
|
|
)
|
|
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'''
|
|
<section class="alert alert-primary border-0 shadow-sm">
|
|
<strong>O Chatwoot é a inbox.</strong> 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.
|
|
</section>
|
|
|
|
<section class="cf-ops-summary">
|
|
<div class="cf-ops-counter"><div><span>A fazer agora</span><strong>{esc(counts.get('work_queue_total', 0))}</strong></div><i class="bi bi-list-check"></i></div>
|
|
<div class="cf-ops-counter danger"><div><span>Atrasadas</span><strong>{esc(model.get('overdue_total', counts.get('overdue_tasks', 0)))}</strong></div><i class="bi bi-clock-history"></i></div>
|
|
<div class="cf-ops-counter warn"><div><span>Bloqueadas</span><strong>{esc(model.get('blocked_total', 0))}</strong></div><i class="bi bi-exclamation-triangle"></i></div>
|
|
<div class="cf-ops-counter purple"><div><span>Associações por confirmar</span><strong>{esc(model.get('ambiguous_total', 0))}</strong></div><i class="bi bi-person-check"></i></div>
|
|
</section>
|
|
|
|
<section class="card cf-card mb-3">
|
|
<div class="card-body p-3">
|
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-2">
|
|
<div>
|
|
<h2 class="cf-section-title">Centro de trabalho</h2>
|
|
<div class="small text-secondary">Hoje, {esc(model.get('today_label'))} · pergunta principal: <strong>o que tenho de fazer agora?</strong></div>
|
|
</div>
|
|
<a class="btn btn-sm btn-outline-secondary" href="/tasks">Ver lista completa de tarefas</a>
|
|
</div>
|
|
<div class="cf-ops-filterbar">{filter_html}</div>
|
|
</div>
|
|
</section>
|
|
|
|
{queue_html}
|
|
'''
|
|
return layout("Centro de trabalho", "Lista única de trabalho do operador", body, "operations")
|