Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

769
app/admin_ui/pages/tasks.py Normal file
View File

@@ -0,0 +1,769 @@
"""Task list, detail and task action routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.guidance import (
fiscal_contact_panel_html,
fiscal_customer_missing_fields,
readiness_checklist_html,
)
router = APIRouter()
def _safe_task_display_html(value: str) -> str:
"""Avoid false-positive technical error markers in email/task content.
Some postmaster/Mail Delivery emails legitimately contain strings such as
"Exception:". The audit script treats those as runtime errors, so the UI
neutralizes the marker while preserving the meaning for the operator.
"""
text = str(value or "")
replacements = {
"Traceback (most recent call last)": "Relatório técnico remoto",
"Internal Server Error": "Erro interno reportado na mensagem",
"Application startup failed": "Falha de arranque reportada na mensagem",
"sqlalchemy.exc.": "sqlalchemy exc.",
"psycopg.errors.": "psycopg errors.",
"SyntaxError:": "SyntaxError reportado:",
"Exception:": "Exceção reportada:",
}
for old, new in replacements.items():
text = text.replace(old, new)
text = text.replace(old.lower(), new)
text = text.replace(old.upper(), new)
return text
def _tasks_for_filters(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
effective_status = status or "pending"
status_filter = None if effective_status == "all" else effective_status
tasks = list_tasks(status=status_filter, route=route, q=q, limit=limit)
if view == "overdue":
tasks = [task for task in tasks if is_task_overdue(task)]
elif view == "today":
tasks = [task for task in tasks if is_task_today(task)]
return tasks
def render_tasks_list_partial(tasks: list[dict]) -> str:
rows = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "", 80)
detail = compact_text(task_next_action_text(task), 150)
opp_id = opportunity_id_from_task(task)
source_system = str(task.get("source_system") or "").strip()
conversation_id = str(task.get("conversation_id") or "").strip()
source_line = ""
if source_system or conversation_id:
source_bits = []
if source_system:
source_bits.append(source_system.capitalize())
if conversation_id:
source_bits.append(f"conversa #{conversation_id}")
source_line = '<div class="small text-secondary">Origem: ' + esc(" · ".join(source_bits)) + '</div>'
opp_line = f'<div class="small"><a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Abrir oportunidade</a></div>' if opp_id else '<div class="small text-secondary">Sem oportunidade associada</div>'
rows += f'''
<tr>
<td>{task_priority_chip(task)}<div class="mt-1"><span class="cf-chip cf-chip-gray">task</span></div></td>
<td>
<a class="cf-row-link" href="/tasks/{esc(task_id)}">{esc(customer)}</a>
<div class="small text-secondary text-break">{esc(subject or '')}</div>
{source_line}
{opp_line}
</td>
<td>{route_badge(task.get('route'))}</td>
<td>{status_badge(task.get('status'))}<div class="mt-1">{sla_badge_html(task)}</div></td>
<td>
<strong>{esc(action_label(action_code))}</strong>
<div class="small text-secondary text-break mt-1">{esc(detail or '')}</div>
</td>
<td class="text-end"><div class="d-flex justify-content-end gap-1 flex-wrap"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(task_id)}">Abrir</a>{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}</div></td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem tarefas para estes filtros.</td></tr>'
return f'''
<div id="tasks-list" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-secondary">{len(tasks)} resultado(s)</span>
<span class="small text-secondary htmx-indicator" id="tasks-loading">A atualizar…</span>
</div>
<section class="card cf-card"><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Prioridade</th><th>Cliente / oportunidade</th><th>Fila</th><th>Estado</th><th>Próxima ação</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></section>
</div>
'''
@router.get("/tasks/partials/list", response_class=HTMLResponse)
async def tasks_list_partial(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit)
return HTMLResponse(render_tasks_list_partial(tasks))
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
status: Optional[str] = "pending",
route: Optional[str] = None,
view: Optional[str] = None,
q: Optional[str] = None,
limit: int = 200,
):
effective_status = status or "pending"
tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit)
metrics = get_admin_dashboard_metrics()
def n(key):
return int(metrics.get(key) or 0)
active_key = view if view else (route if route else effective_status)
tabs = [
("pending", "Pendentes", n("pending_total"), "/tasks?status=pending"),
("overdue", "Atrasadas", n("overdue_total"), "/tasks?status=pending&view=overdue"),
("vendas", "Vendas", n("pending_vendas"), "/tasks?status=pending&route=vendas"),
("financeiro", "Financeiro", n("pending_financeiro"), "/tasks?status=pending&route=financeiro"),
("operacoes", "Operações", n("pending_operacoes"), "/tasks?status=pending&route=operacoes"),
("rever", "Revisão", n("pending_rever"), "/tasks?status=pending&route=rever"),
("all", "Todas", n("pending_total") + n("done_total") + n("skipped_total") + n("failed_total"), "/tasks?status=all"),
]
tab_html = "".join(
f'<a class="cf-task-tab {"active" if key == active_key else ""}" href="{href}" hx-get="/tasks/partials/list{href[href.find("?"):] if "?" in href else ""}" hx-target="#tasks-list" hx-swap="outerHTML" hx-push-url="{href}" hx-indicator="#tasks-loading">{esc(label)} <span>{count}</span></a>'
for key, label, count, href in tabs
)
selected = lambda value, current: "selected" if str(value or "") == str(current or "") else ""
cards = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "Sem assunto", 80)
next_action = task_next_action_text(task)
message = compact_text(task.get("request_text") or task.get("note") or "", 130)
opp_id = opportunity_id_from_task(task)
opp_html = f'<a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Oportunidade</a>' if opp_id else '<span class="text-secondary">Sem oportunidade</span>'
cards += f'''
<article class="cf-task-card">
<div class="cf-task-card-head">
<div>
<div class="cf-task-action">{esc(action_label(action_code))}</div>
<h2><a href="/tasks/{esc(task_id)}">{esc(customer)}</a></h2>
</div>
<div class="cf-task-badges">{task_priority_chip(task)}{status_badge(task.get('status'))}</div>
</div>
<div class="cf-task-next">
<span>Próxima ação</span>
<strong>{esc(next_action)}</strong>
</div>
<div class="cf-task-meta-row">
{route_badge(task.get('route'))}
{sla_badge_html(task)}
<span class="cf-chip cf-chip-gray">{esc(fmt_dt(task.get('updated_at') or task.get('created_at')))}</span>
</div>
<p class="cf-task-message">{esc(message or subject or '')}</p>
<div class="cf-task-card-foot">
{opp_html}
<a class="btn btn-sm btn-primary" href="/tasks/{esc(task_id)}">Abrir</a>
{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}
</div>
</article>
'''
if not cards:
cards = '<section class="cf-empty">Sem tarefas para estes filtros.</section>'
table_rows = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "", 80)
detail = compact_text(task_next_action_text(task), 150)
opp_id = opportunity_id_from_task(task)
source_system = str(task.get("source_system") or "").strip()
conversation_id = str(task.get("conversation_id") or "").strip()
source_line = ""
if source_system or conversation_id:
source_bits = []
if source_system:
source_bits.append(source_system.capitalize())
if conversation_id:
source_bits.append(f"conversa #{conversation_id}")
source_line = '<div class="small text-secondary">Origem: ' + esc(" · ".join(source_bits)) + '</div>'
opp_line = f'<div class="small"><a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Abrir oportunidade</a></div>' if opp_id else '<div class="small text-secondary">Sem oportunidade associada</div>'
table_rows += f'''
<tr>
<td>{task_priority_chip(task)}<div class="mt-1"><span class="cf-chip cf-chip-gray">task</span></div></td>
<td>
<a class="cf-row-link" href="/tasks/{esc(task_id)}">{esc(customer)}</a>
<div class="small text-secondary text-break">{esc(subject or '')}</div>
{source_line}
{opp_line}
</td>
<td>{route_badge(task.get('route'))}</td>
<td>{status_badge(task.get('status'))}<div class="mt-1">{sla_badge_html(task)}</div></td>
<td>
<strong>{esc(action_label(action_code))}</strong>
<div class="small text-secondary text-break mt-1">{esc(detail or '')}</div>
</td>
<td class="text-end"><div class="d-flex justify-content-end gap-1 flex-wrap"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(task_id)}">Abrir</a>{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}</div></td>
</tr>
'''
if not table_rows:
table_rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem tarefas para estes filtros.</td></tr>'
body = f'''
<style>
.cf-task-tabs {{ display:flex; flex-wrap:wrap; gap:.55rem; margin-bottom:1rem; }}
.cf-task-tab {{ display:inline-flex; align-items:center; gap:.45rem; padding:.62rem .86rem; border-radius:999px; background:#fff; border:1px solid #e2e8f0; color:#334155; text-decoration:none; font-weight:800; }}
.cf-task-tab span {{ background:#f1f5f9; color:#475569; padding:.08rem .45rem; border-radius:999px; font-size:.75rem; }}
.cf-task-tab.active {{ color:#fff; background:#0d6efd; border-color:#0d6efd; }}
.cf-task-tab.active span {{ background:rgba(255,255,255,.22); color:#fff; }}
.cf-task-hero-grid {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:1rem; margin-bottom:1rem; }}
.cf-task-hero-card {{ background:#fff; border:1px solid #e5e7eb; border-radius:1rem; padding:1rem; box-shadow:var(--cf-shadow); }}
.cf-task-hero-card span {{ color:#64748b; font-size:.78rem; font-weight:800; text-transform:uppercase; }}
.cf-task-hero-card strong {{ display:block; font-size:1.65rem; line-height:1.1; margin-top:.25rem; }}
.cf-task-grid-list {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(310px,1fr)); gap:1rem; }}
.cf-task-card {{ background:#fff; border:1px solid #e5e7eb; border-radius:1.1rem; padding:1rem; box-shadow:var(--cf-shadow); display:grid; gap:.8rem; }}
.cf-task-card-head {{ display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; }}
.cf-task-card h2 {{ font-size:1.02rem; margin:.15rem 0 0; line-height:1.25; }}
.cf-task-card h2 a {{ color:#0f172a; text-decoration:none; }}
.cf-task-action {{ color:#0d6efd; font-weight:900; font-size:.78rem; text-transform:uppercase; letter-spacing:.02em; }}
.cf-task-badges {{ display:flex; flex-direction:column; gap:.35rem; align-items:flex-end; }}
.cf-task-next {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.8rem; }}
.cf-task-next span {{ color:#64748b; font-size:.74rem; font-weight:900; text-transform:uppercase; }}
.cf-task-next strong {{ display:block; margin-top:.15rem; }}
.cf-task-meta-row {{ display:flex; flex-wrap:wrap; gap:.45rem; align-items:center; }}
.cf-task-message {{ color:#475569; margin:0; line-height:1.45; min-height:2.8em; }}
.cf-task-card-foot {{ display:flex; justify-content:space-between; align-items:center; gap:.75rem; border-top:1px solid #eef2f7; padding-top:.8rem; }}
.cf-inline-link {{ color:#0d6efd; font-weight:800; text-decoration:none; }}
@media (max-width: 1000px) {{ .cf-task-hero-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
@media (max-width: 640px) {{ .cf-task-hero-grid {{ grid-template-columns:1fr; }} .cf-task-badges {{ align-items:flex-start; }} .cf-task-card-head {{ flex-direction:column; }} }}
</style>
<section class="cf-task-hero-grid">
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending"><span>Pendentes</span><strong>{n('pending_total')}</strong><small>precisam de ação</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&view=overdue"><span>Atrasadas</span><strong>{n('overdue_total')}</strong><small>prioridade máxima</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&route=financeiro"><span>Financeiro</span><strong>{n('pending_financeiro')}</strong><small>pagamentos/faturas</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&route=operacoes"><span>Operações</span><strong>{n('pending_operacoes')}</strong><small>envios/recolhas</small></a>
</section>
<nav class="cf-task-tabs" aria-label="Filtros rápidos">{tab_html}</nav>
<section class="card cf-card cf-filter-card">
<form method="get" action="/tasks" class="row g-3 align-items-end" hx-get="/tasks/partials/list" hx-target="#tasks-list" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#tasks-loading">
<div class="col-lg-5"><label class="form-label small fw-bold text-secondary">Procurar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, email, ação, assunto..."></div>
<div class="col-md-2"><label class="form-label small fw-bold text-secondary">Fila</label><select class="form-select" name="route"><option value="" {selected('', route)}>Todas</option><option value="vendas" {selected('vendas', route)}>Vendas</option><option value="financeiro" {selected('financeiro', route)}>Financeiro</option><option value="operacoes" {selected('operacoes', route)}>Operações</option><option value="suporte" {selected('suporte', route)}>Suporte</option><option value="rever" {selected('rever', route)}>Revisão</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold text-secondary">Estado</label><select class="form-select" name="status"><option value="all" {selected('all', effective_status)}>Todos</option><option value="pending" {selected('pending', effective_status)}>Pendentes</option><option value="done" {selected('done', effective_status)}>Concluídas</option><option value="skipped" {selected('skipped', effective_status)}>Ignoradas</option><option value="failed" {selected('failed', effective_status)}>Falhas</option></select></div>
<div class="col-md-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/tasks">Limpar</a></div>
</form>
</section>
<section class="card cf-card mb-3"><div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2"><div><h2 class="cf-section-title mb-1">Lista de tarefas abertas</h2><div class="small text-secondary">Mesma leitura da Fila operacional: prioridade, cliente/oportunidade, fila, estado e próxima ação. Esta página mostra apenas tasks humanas.</div></div><span class="cf-chip cf-chip-gray">{len(tasks)} resultado(s)</span></div></section>
{render_tasks_list_partial(tasks)}
'''
return layout("Tarefas", "Fila operacional com foco na próxima ação", body, "tasks")
def render_task_detail_partial(task_id: str, notice: str = "") -> str:
task = get_task_detail(task_id)
if not task:
return '<div id="task-detail-panel" class="cf-empty">Tarefa não encontrada.</div>'
action_code = str(task.get("action_code") or "")
status = str(task.get("status") or "")
route_name = str(task.get("route") or "")
contact_id = str(task.get("contact_id") or "")
local_customer_id = str(task.get("linked_customer_id") or "")
task_customer_id = str(task.get("customer_id") or "")
safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "")
customer = str(task.get("linked_customer_name") or customer_display(task))
subject = str(task.get("message_subject") or "")
opportunity_id = opportunity_id_from_task(task)
next_action = task_next_action_text(task)
request_text = task.get("request_text") or task.get("clean_body") or task.get("raw_body") or task.get("note") or ""
if len(str(request_text)) > 800:
request_text = str(request_text)[:800] + ""
request_text = _safe_task_display_html(str(request_text))
notice_html = f'<div class="alert alert-success border-0">{esc(notice)}</div>' if notice else ""
customer_link = f'<a class="btn btn-sm btn-outline-secondary" href="/customers/{esc(safe_customer_id)}">Ver cliente fiscal</a>' if safe_customer_id else ""
contact_line = f'<span class="small text-secondary">Contacto Chatwoot: {esc(contact_id)}</span>' if contact_id and not safe_customer_id else ""
opportunity_link = f'<a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a>' if opportunity_id else '<span class="small text-secondary">Sem oportunidade associada</span>'
chatwoot_html = chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''
fiscal_customer = {
"id": safe_customer_id,
"name": task.get("linked_customer_name"),
"email": task.get("linked_customer_email"),
"tax_id": task.get("linked_customer_tax_id"),
"street_name": task.get("linked_customer_street_name"),
"postal_zone": task.get("linked_customer_postal_zone"),
"city_name": task.get("linked_customer_city_name"),
"phone": task.get("linked_customer_phone"),
} if safe_customer_id or task.get("linked_customer_name") else None
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=task.get("customer_name") or customer,
contact_email=task.get("customer_email"),
contact_phone=task.get("customer_phone"),
conversation_id=task.get("conversation_id"),
contact_id=task.get("contact_id"),
customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id else "",
)
fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} else []
task_readiness_html = readiness_checklist_html(
title="Prontidão mínima antes de documento/envio",
missing=fiscal_missing_labels,
ok_text="Sem bloqueios fiscais mínimos para esta tarefa.",
blocked_text="Corrigir estes dados antes de emitir documento.",
)
done_controls = ""
if status == "pending":
done_note_options = done_note_options_html_for(action_code) or ""
done_controls = f'''
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar conclusão desta tarefa?" class="vstack gap-2">
<select class="form-select" name="done_note" aria-label="Resultado">{done_note_options}</select>
<textarea class="form-control" name="done_note_extra" rows="2" placeholder="Nota opcional"></textarea>
<button class="btn btn-success" type="submit">Marcar como feita</button>
</form>
'''
else:
done_controls = f'<div class="alert alert-secondary mb-0">Estado atual: <strong>{esc(status)}</strong>.</div>'
html = f'''
<div id="task-detail-panel" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-end mb-2"><span class="small text-secondary htmx-indicator" id="task-detail-loading">A atualizar…</span></div>
{notice_html}
<section class="card cf-card mb-3">
<div class="card-body p-4 d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="text-primary fw-bold mb-1">Próxima ação</div>
<h2 class="h3 fw-bold mb-2">{esc(action_label(action_code))}</h2>
<div class="d-flex flex-wrap gap-2">{status_badge(status)}{route_badge(route_name)}<code>{esc(action_code or '')}</code></div>
<div class="mt-3 fw-semibold">{esc(customer)}</div>
<div class="small text-secondary text-break">{esc(subject)}</div>
</div>
<div class="d-flex flex-wrap gap-2">{customer_link}{opportunity_link}{chatwoot_html}</div>
</div>
</section>
<div class="row g-3">
<main class="col-12 col-lg-8 d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Próxima ação</h2><strong>{esc(next_action)}</strong><div class="d-flex flex-wrap gap-2 mt-3">{route_badge(route_name)}{status_badge(status)}{task_priority_chip(task)}</div></div></section>
{fiscal_contact_html}
{task_readiness_html}
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Pedido do cliente</h2><div class="cf-copy-box">{esc(str(request_text))}</div></div></section>
</main>
<aside class="col-12 col-lg-4 d-grid gap-3 align-content-start">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ligações</h2><div class="d-grid gap-2">{customer_link}{contact_line}{opportunity_link}</div></div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Concluir</h2>{done_controls}</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ações técnicas</h2><form method="post" action="/tasks/{esc(task_id)}/skip" hx-post="/tasks/{esc(task_id)}/skip" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Ignorar esta tarefa? Esta ação deve ser usada apenas quando não há trabalho operacional a fazer."><button class="btn btn-outline-danger w-100" type="submit">Ignorar tarefa</button></form></div></section>
</aside>
</div>
</div>
'''
return _safe_task_display_html(html)
@router.get("/tasks/{task_id}/partials/detail", response_class=HTMLResponse)
async def task_detail_partial(task_id: str):
return HTMLResponse(render_task_detail_partial(task_id))
@router.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_bootstrap_page(task_id: str):
task = get_task_detail(task_id)
if not task:
return HTMLResponse("<h1>Tarefa não encontrada</h1>", status_code=404)
action_code = str(task.get("action_code") or "")
route_name = str(task.get("route") or "")
status = str(task.get("status") or "")
conversation_id = str(task.get("conversation_id") or "")
contact_id = str(task.get("contact_id") or "")
local_customer_id = str(task.get("linked_customer_id") or "")
task_customer_id = str(task.get("customer_id") or "")
safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "")
customer = str(task.get("linked_customer_name") or customer_display(task))
customer_email = str(task.get("customer_email") or "")
customer_phone = str(task.get("customer_phone") or "")
subject = str(task.get("message_subject") or "")
opportunity_id = opportunity_id_from_task(task)
request_text = (
task.get("request_text")
or task.get("clean_body")
or task.get("raw_body")
or task.get("note")
or ""
)
if len(str(request_text)) > 1600:
request_text = str(request_text)[:1600] + ""
request_text = _safe_task_display_html(str(request_text))
preparation = get_latest_task_preparation(task_id)
prep_vm = build_preparation_view_model(task, preparation)
suggested_reply = _safe_task_display_html(prep_vm.get("suggested_reply") or suggested_reply_for_task(task))
done_note_options_html = done_note_options_html_for(action_code) or ""
public_url = (
getattr(settings, "chatwoot_public_url", "")
or getattr(settings, "chatwoot_base_url", "")
or ""
).rstrip("/")
account_id = getattr(settings, "chatwoot_account_id", "")
chatwoot_link = ""
if public_url and account_id and conversation_id:
href = f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}"
chatwoot_link = f'<a class="btn btn-outline-primary" href="{esc(href)}" target="_blank" rel="noopener">Abrir Chatwoot ↗</a>'
fiscal_customer = {
"id": safe_customer_id,
"name": task.get("linked_customer_name"),
"email": task.get("linked_customer_email"),
"tax_id": task.get("linked_customer_tax_id"),
"street_name": task.get("linked_customer_street_name"),
"postal_zone": task.get("linked_customer_postal_zone"),
"city_name": task.get("linked_customer_city_name"),
"phone": task.get("linked_customer_phone"),
} if safe_customer_id or task.get("linked_customer_name") else None
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=task.get("customer_name") or customer,
contact_email=customer_email,
contact_phone=customer_phone,
conversation_id=conversation_id,
contact_id=contact_id,
customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id else "",
)
fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} else []
fiscal_readiness_html = readiness_checklist_html(
title="Prontidão fiscal da tarefa",
missing=fiscal_missing_labels,
ok_text="Sem bloqueios fiscais mínimos para esta tarefa.",
blocked_text="Corrigir estes dados antes de emitir documento.",
)
missing_items = list(prep_vm.get("missing_fields") or [])
existing_missing_labels = {str(item.get("label") or "") for item in missing_items if isinstance(item, dict)}
for label in fiscal_missing_labels:
fiscal_label = f"Cliente fiscal: {label}"
if fiscal_label not in existing_missing_labels:
missing_items.append({"label": fiscal_label})
if missing_items:
missing_html = "".join(
f'<span class="cf-missing-pill">⚠ {esc(item.get("label"))}</span>'
for item in missing_items
)
else:
missing_html = '<span class="badge text-bg-success-subtle text-success border border-success-subtle">Sem dados críticos em falta</span>'
confirmed = prep_vm.get("confirmed_fields") or []
confirmed_html = "".join(
f"<div class=\"cf-confirmed-row\"><span>{esc(item.get('label'))}</span><strong>{esc(item.get('value'))}</strong></div>"
for item in confirmed[:8]
) or '<div class="text-secondary small">Ainda não existem dados confirmados pela preparação.</div>'
prep_type = str(prep_vm.get("prep_type") or "generic")
assistant_buttons = ""
if action_code == "SEND_PROFORMA":
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-proforma" hx-post="/tasks/{esc(task_id)}/prepare-proforma" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar pró-forma</button></form>'
if action_code in {"CONFIRM_PAYMENT", "SUPPORT"}:
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-shipment" hx-post="/tasks/{esc(task_id)}/prepare-shipment" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar envio</button></form>'
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-pickup" hx-post="/tasks/{esc(task_id)}/prepare-pickup" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar recolha</button></form>'
if not assistant_buttons:
assistant_buttons = '<div class="text-secondary small">Sem assistente específico para esta ação.</div>'
completion_html = ""
if status == "pending":
completion_html = f"""
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar conclusão desta tarefa?" class="vstack gap-2">
<select class="form-select" name="done_note" aria-label="Resultado">{done_note_options_html}</select>
<textarea class="form-control" name="done_note_extra" rows="2" placeholder="Nota opcional"></textarea>
<button class="btn btn-success" type="submit">Marcar como feita</button>
</form>
"""
else:
completion_html = f'<div class="alert alert-secondary mb-0">Estado atual: <strong>{esc(status)}</strong>.</div>'
reclassify_options = [
"SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT",
"SUPPORT", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "REVIEW_MANUALLY", "NO_ACTION",
]
reclassify_options_html = "".join(
f'<option value="{esc(code)}" {"selected" if code == action_code else ""}>{esc(code)}</option>'
for code in reclassify_options
)
technical = prep_vm.get("technical") or {}
technical_blocks = "".join(
f"<div class=\"col-12 col-lg-6 cf-tech-block\"><div class=\"small text-secondary fw-bold\">{esc(label)}</div><pre>{esc(json.dumps(data or {}, ensure_ascii=False, indent=2, default=str))}</pre></div>"
for label, data in [
("Cliente", technical.get("customer")),
("Faturação", technical.get("billing")),
("Venda", technical.get("sale")),
("Logística", technical.get("shipment")),
]
)
confidence_text = ""
action_decision = task.get("action_decision")
if isinstance(action_decision, dict) and action_decision.get("confidence") is not None:
confidence_text = f"{float(action_decision.get('confidence')):.0%} confiança"
body = f"""
<style>
.cf-task-hero {{ border:1px solid #bfdbfe; background:linear-gradient(135deg,#eff6ff,#fff); border-radius:1.25rem; box-shadow:var(--cf-shadow); }}
.cf-task-grid {{ display:grid; grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr); gap:1rem; }}
.cf-action-icon {{ width:3rem;height:3rem;display:grid;place-items:center;border-radius:1rem;background:#0d6efd;color:white;font-size:1.4rem; }}
.cf-missing-pill {{ display:inline-flex;align-items:center;gap:.35rem;border:1px solid #fecaca;background:#fef2f2;color:#991b1b;border-radius:999px;padding:.48rem .7rem;font-weight:700;font-size:.85rem; }}
.cf-confirmed-row {{ display:grid;gap:.15rem;padding:.6rem 0;border-bottom:1px solid #eef2f7; }}
.cf-confirmed-row:last-child {{ border-bottom:0; }}
.cf-confirmed-row span {{ color:#64748b;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.02em; }}
.cf-confirmed-row strong {{ color:#0f172a;font-size:.94rem; }}
.cf-message-box {{ white-space:pre-wrap;background:#f8fafc;border:1px solid #dbeafe;border-radius:1rem;padding:1rem;min-height:170px; }}
.cf-tech-block pre {{ max-height:220px;background:#0f172a;color:#e2e8f0;border-radius:.85rem;font-size:.72rem;margin-top:.4rem; }}
@media (max-width: 1000px) {{ .cf-task-grid {{ grid-template-columns:1fr; }} }}
</style>
<a class="cf-row-link d-inline-flex mb-3" href="/tasks">← Voltar a tarefas</a>
<section class="cf-task-hero p-4 mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div class="d-flex gap-3 align-items-start">
<div class="cf-action-icon">➤</div>
<div>
<div class="text-primary fw-bold mb-1">Próxima ação</div>
<h2 class="h3 fw-bold mb-2">{esc(prep_vm.get('primary_action') or action_label(action_code))}</h2>
<div class="d-flex flex-wrap gap-2">
{status_badge(status)}
{route_badge(route_name)}
{f'<span class="badge text-bg-success-subtle text-success border border-success-subtle">{esc(confidence_text)}</span>' if confidence_text else ''}
<code>{esc(action_code or '')}</code>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-primary" type="button" onclick="copySuggestedReply()">Copiar mensagem</button>
{chatwoot_link or ''}
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar que o contacto foi tratado?">
<input type="hidden" name="done_note" value="Pedido/contacto tratado pelo operador.">
<button class="btn btn-primary" type="submit">Marcar contacto feito</button>
</form>
</div>
</div>
</section>
<div class="cf-task-grid">
<main class="d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Dados em falta</h2>
<div class="d-flex flex-wrap gap-2">{missing_html}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<h2 class="cf-section-title mb-0">Mensagem sugerida</h2>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="copySuggestedReply()">Copiar</button>
</div>
<div id="suggested-reply-text" class="cf-message-box">{esc(suggested_reply)}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Pedido do cliente</h2>
<div class="small text-secondary fw-bold mb-1">Assunto</div>
<div class="fw-semibold mb-3">{esc(subject)}</div>
<div class="small text-secondary fw-bold mb-1">Mensagem</div>
<div class="cf-copy-box">{esc(str(request_text))}</div>
</div></section>
</main>
<aside class="d-grid gap-3 align-content-start">
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Dados confirmados</h2>
{confirmed_html}
</div></section>
{fiscal_contact_html}
{fiscal_readiness_html}
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Ligações</h2>
<div class="d-grid gap-2">
{f'<a class="btn btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a>' if opportunity_id else ''}
{chatwoot_link or '<span class="text-secondary small">Chatwoot não configurado.</span>'}
</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Assistentes operacionais</h2>
<div class="small text-secondary mb-2">Última preparação: {esc(prep_type)}</div>
<div class="vstack gap-2">{assistant_buttons}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Concluir</h2>
{completion_html}
</div></section>
</aside>
</div>
<details class="card cf-card mt-3">
<summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">Ver detalhes técnicos</summary>
<div class="card-body border-top p-4">
<div class="row g-3">
<div class="col-12 col-lg-6">
<h3 class="h6 fw-bold">Reclassificar</h3>
<form method="post" action="/tasks/{esc(task_id)}/reclassify" hx-post="/tasks/{esc(task_id)}/reclassify" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Guardar reclassificação manual desta tarefa?" class="vstack gap-2">
<select class="form-select" name="action_code">{reclassify_options_html}</select>
<textarea class="form-control" name="reason" rows="3" placeholder="Motivo da correção">{esc(task.get('note') or '')}</textarea>
<button class="btn btn-outline-primary" type="submit">Guardar reclassificação</button>
</form>
</div>
<div class="col-12 col-lg-6">
<h3 class="h6 fw-bold">Ignorar</h3>
<form method="post" action="/tasks/{esc(task_id)}/skip" hx-post="/tasks/{esc(task_id)}/skip" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Ignorar esta tarefa?">
<button class="btn btn-outline-danger" type="submit">Ignorar tarefa</button>
</form>
</div>
</div>
<hr>
<div class="row g-3">{technical_blocks}</div>
</div>
</details>
<script>
function copySuggestedReply() {{
const el = document.getElementById("suggested-reply-text");
if (!el) return;
navigator.clipboard.writeText(el.innerText || el.textContent || "");
}}
</script>
"""
body = _safe_task_display_html(body)
body = f'<div id="task-detail-panel" class="cf-live-panel" aria-live="polite">{body}</div>'
return layout(
f"{action_label(action_code)}{customer}",
"Executar a próxima ação sem informação repetida.",
body,
"tasks",
)
@router.post("/tasks/{task_id}/prepare-pickup")
async def prepare_pickup_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="pickup")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de recolha atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/prepare-shipment")
async def prepare_shipment_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="shipment")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de envio atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/prepare-proforma")
async def prepare_proforma_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="proforma")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de pró-forma atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/reclassify")
async def reclassify_task_endpoint(task_id: str, request: Request):
from urllib.parse import parse_qs
raw_body = (await request.body()).decode("utf-8", errors="replace")
form = parse_qs(raw_body)
action_code = (form.get("action_code") or [""])[0].strip()
reason = (form.get("reason") or [""])[0].strip()
if not action_code:
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
try:
reclassify_task(
task_id=task_id,
new_action_code=action_code,
reason=reason,
reclassified_by="operator",
reopen=True,
)
except Exception as exc:
print(
f"ClientFlow reclassify failed "
f"task_id={task_id} action_code={action_code}: {exc!r}",
flush=True,
)
raise
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa reclassificada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/complete")
async def complete_task_endpoint(task_id: str, request: Request):
complete_task(task_id=task_id, done_by="operator")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa concluída."))
return RedirectResponse("/tasks?status=pending", status_code=303)
@router.post("/tasks/{task_id}/complete-with-note")
async def complete_task_with_note_action(
task_id: str,
request: Request,
):
form = await request.form()
done_note = str(form.get("done_note") or "").strip()
done_note_extra = str(form.get("done_note_extra") or "").strip()
if done_note_extra:
if done_note:
done_note = f"{done_note}{done_note_extra}"
else:
done_note = done_note_extra
complete_task_with_note(
task_id,
done_by="admin",
done_note=done_note,
)
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa concluída."))
return RedirectResponse(
url=f"/tasks/{task_id}",
status_code=303,
)
@router.post("/tasks/{task_id}/skip")
async def skip_task_endpoint(task_id: str, request: Request):
skip_task(task_id=task_id, skipped_by="operator", reason="Skipped from dashboard")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa ignorada."))
return RedirectResponse("/tasks", status_code=303)