"""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 = '
{route_badge(task.get('route'))}
{sla_badge_html(task)}
{esc(fmt_dt(task.get('updated_at') or task.get('created_at')))}
{esc(message or subject or '—')}
{opp_html}
Abrir
{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}
'''
if not cards:
cards = 'Sem tarefas para estes filtros.'
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 = '
Mesma leitura da Fila operacional: prioridade, cliente/oportunidade, fila, estado e próxima ação. Esta página mostra apenas tasks humanas.
{len(tasks)} resultado(s)
{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 '
Tarefa não encontrada.
'
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'
{esc(notice)}
' if notice else ""
customer_link = f'Ver cliente fiscal' if safe_customer_id else ""
contact_line = f'Contacto Chatwoot: {esc(contact_id)}' if contact_id and not safe_customer_id else ""
opportunity_link = f'Ver oportunidade' if opportunity_id else 'Sem oportunidade associada'
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'''
'''
else:
done_controls = f'
Estado atual: {esc(status)}.
'
html = f'''
A atualizar…
{notice_html}
Próxima ação
{esc(action_label(action_code))}
{status_badge(status)}{route_badge(route_name)}{esc(action_code or '—')}
{esc(json.dumps(data or {}, ensure_ascii=False, indent=2, default=str))}
"
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"""
← Voltar a tarefas
➤
Próxima ação
{esc(prep_vm.get('primary_action') or action_label(action_code))}
{status_badge(status)}
{route_badge(route_name)}
{f'{esc(confidence_text)}' if confidence_text else ''}
{esc(action_code or '—')}
{chatwoot_link or ''}
Dados em falta
{missing_html}
Mensagem sugerida
{esc(suggested_reply)}
Pedido do cliente
Assunto
{esc(subject)}
Mensagem
{esc(str(request_text))}
Ver detalhes técnicos
Reclassificar
Ignorar
{technical_blocks}
"""
body = _safe_task_display_html(body)
body = f'
{body}
'
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)