"""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 = '
Origem: ' + esc(" · ".join(source_bits)) + '
' opp_line = f'
Abrir oportunidade
' if opp_id else '
Sem oportunidade associada
' rows += f''' {task_priority_chip(task)}
task
{esc(customer)}
{esc(subject or '—')}
{source_line} {opp_line} {route_badge(task.get('route'))} {status_badge(task.get('status'))}
{sla_badge_html(task)}
{esc(action_label(action_code))}
{esc(detail or '—')}
Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}
''' if not rows: rows = 'Sem tarefas para estes filtros.' return f'''
{len(tasks)} resultado(s) A atualizar…
{rows}
PrioridadeCliente / oportunidadeFilaEstadoPróxima ação
''' @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'{esc(label)} {count}' 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'Oportunidade' if opp_id else 'Sem oportunidade' cards += f'''
{esc(action_label(action_code))}

{esc(customer)}

{task_priority_chip(task)}{status_badge(task.get('status'))}
Próxima ação {esc(next_action)}
{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 = '
Origem: ' + esc(" · ".join(source_bits)) + '
' opp_line = f'
Abrir oportunidade
' if opp_id else '
Sem oportunidade associada
' table_rows += f''' {task_priority_chip(task)}
task
{esc(customer)}
{esc(subject or '—')}
{source_line} {opp_line} {route_badge(task.get('route'))} {status_badge(task.get('status'))}
{sla_badge_html(task)}
{esc(action_label(action_code))}
{esc(detail or '—')}
Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}
''' if not table_rows: table_rows = 'Sem tarefas para estes filtros.' body = f'''
Pendentes{n('pending_total')}precisam de ação Atrasadas{n('overdue_total')}prioridade máxima Financeiro{n('pending_financeiro')}pagamentos/faturas Operações{n('pending_operacoes')}envios/recolhas
Limpar

Lista de tarefas abertas

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(customer)}
{esc(subject)}
{customer_link}{opportunity_link}{chatwoot_html}

Próxima ação

{esc(next_action)}
{route_badge(route_name)}{status_badge(status)}{task_priority_chip(task)}
{fiscal_contact_html} {task_readiness_html}

Pedido do cliente

{esc(str(request_text))}
''' 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("

Tarefa não encontrada

", 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'Abrir Chatwoot ↗' 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'⚠ {esc(item.get("label"))}' for item in missing_items ) else: missing_html = 'Sem dados críticos em falta' confirmed = prep_vm.get("confirmed_fields") or [] confirmed_html = "".join( f"
{esc(item.get('label'))}{esc(item.get('value'))}
" for item in confirmed[:8] ) or '
Ainda não existem dados confirmados pela preparação.
' prep_type = str(prep_vm.get("prep_type") or "generic") assistant_buttons = "" if action_code == "SEND_PROFORMA": assistant_buttons += f'
' if action_code in {"CONFIRM_PAYMENT", "SUPPORT"}: assistant_buttons += f'
' assistant_buttons += f'
' if not assistant_buttons: assistant_buttons = '
Sem assistente específico para esta ação.
' completion_html = "" if status == "pending": completion_html = f"""
""" else: completion_html = f'
Estado atual: {esc(status)}.
' 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'' for code in reclassify_options ) technical = prep_vm.get("technical") or {} technical_blocks = "".join( f"
{esc(label)}
{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)