Release v4928.1.4.2 stable
This commit is contained in:
15
app/admin_ui/pages/README.md
Normal file
15
app/admin_ui/pages/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Admin UI pages
|
||||
|
||||
Incremental extraction target for `app/admin_dashboard.py`.
|
||||
|
||||
Planned page modules:
|
||||
|
||||
- `customers.py`
|
||||
- `opportunities.py`
|
||||
- `products.py`
|
||||
- `documents.py`
|
||||
- `shipments.py`
|
||||
- `integrations.py`
|
||||
- `outbox.py`
|
||||
|
||||
Public routes should stay unchanged while code is migrated page-by-page.
|
||||
1
app/admin_ui/pages/__init__.py
Normal file
1
app/admin_ui/pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Domain route modules for the ClientFlow admin UI."""
|
||||
201
app/admin_ui/pages/communications.py
Normal file
201
app/admin_ui/pages/communications.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Communication diagnostic 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/communications", response_class=HTMLResponse)
|
||||
@router.get("/comunicacoes", response_class=HTMLResponse)
|
||||
async def communications_page(status: Optional[str] = None, classification: Optional[str] = None, q: Optional[str] = None, scope: Optional[str] = None):
|
||||
items = list_communications(status=status, classification=classification, q=q, limit=120)
|
||||
if scope == "without_customer":
|
||||
items = [i for i in items if not i.get("customer_id") and str(i.get("direction") or "inbound") == "inbound"]
|
||||
elif scope == "without_opportunity":
|
||||
items = [i for i in items if not i.get("opportunity_id") and str(i.get("direction") or "inbound") == "inbound"]
|
||||
elif scope == "needs_review":
|
||||
items = [i for i in items if str(i.get("status") or "") == "needs_review"]
|
||||
|
||||
summary = get_communications_summary()
|
||||
|
||||
rows = ""
|
||||
for item in items:
|
||||
action = classification_action(item.get("classification"))
|
||||
confidence = item.get("confidence")
|
||||
confidence_label = "—" if confidence is None else f"{float(confidence):.2f}"
|
||||
linked = []
|
||||
if item.get("customer_name"):
|
||||
linked.append(str(item.get("customer_name")))
|
||||
elif item.get("customer_id"):
|
||||
linked.append("cliente associado")
|
||||
else:
|
||||
linked.append("sem cliente")
|
||||
if item.get("opportunity_title"):
|
||||
linked.append(str(item.get("opportunity_title")))
|
||||
elif item.get("opportunity_id"):
|
||||
linked.append("oportunidade associada")
|
||||
else:
|
||||
linked.append("sem oportunidade")
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><a class="cf-row-link" href="/communications/{esc(item.get('id'))}">{esc(item.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(item.get('sender_name') or item.get('sender_email') or '—')} · {esc(fmt_dt(item.get('created_at')))}</div></td>
|
||||
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><div class="small text-secondary">confiança {esc(confidence_label)}</div></td>
|
||||
<td>{status_badge(item.get('status'))}<div class="small text-secondary">{esc(action.get('queue'))} · {esc(action.get('action'))}</div></td>
|
||||
<td class="small text-secondary text-break">{esc(' · '.join(linked))}</td>
|
||||
<td><a class="btn btn-sm btn-primary" href="/communications/{esc(item.get('id'))}">Abrir</a></td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="5" class="text-secondary py-4">Sem comunicações para os filtros selecionados.</td></tr>'
|
||||
|
||||
status_options = ["", "new", "classified", "needs_review", "linked", "task_created", "done", "ignored"]
|
||||
status_select = "".join(f'<option value="{esc(v)}" {"selected" if (status or "") == v else ""}>{esc(v or "todos")}</option>' for v in status_options)
|
||||
|
||||
body = f"""
|
||||
<section class="alert alert-primary border-0 shadow-sm">
|
||||
<strong>Comunicações = emails/mensagens classificados.</strong>
|
||||
Quando uma comunicação exige ação humana, ela também aparece no Centro de trabalho e pode gerar task.
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Total', summary.get('total', 0), '/communications', 'comunicações registadas', 'bi-inbox')}
|
||||
{kpi_card('Por tratar', summary.get('open', 0), '/communications', 'new/classified/review', 'bi-envelope-exclamation')}
|
||||
{kpi_card('Baixa confiança', summary.get('needs_review', 0), '/communications?status=needs_review', 'precisa revisão', 'bi-shield-exclamation')}
|
||||
{kpi_card('Sem cliente', summary.get('without_customer', 0), '/communications?scope=without_customer', 'associar contacto', 'bi-person-plus')}
|
||||
</section>
|
||||
|
||||
<form class="filters row g-2 align-items-end" method="get" action="/communications">
|
||||
<div class="col-md-4"><label class="form-label small fw-bold">Pesquisar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="email, assunto, cliente..."></div>
|
||||
<div class="col-md-3"><label class="form-label small fw-bold">Estado</label><select class="form-select" name="status">{status_select}</select></div>
|
||||
<div class="col-md-3"><label class="form-label small fw-bold">Classificação</label><input class="form-control" type="text" name="classification" value="{esc(classification or '')}" placeholder="pedido_orcamento"></div>
|
||||
<div class="col-md-2"><button class="btn btn-primary w-100" type="submit">Filtrar</button></div>
|
||||
</form>
|
||||
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Inbox classificada</h2><div class="small text-secondary">Emails e mensagens recebidas, com classificação e contexto operacional.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado / ação sugerida</th><th>Cliente / oportunidade</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
|
||||
"""
|
||||
return layout("Comunicações", "Inbox classificada de emails e mensagens", body, "communications")
|
||||
|
||||
|
||||
@router.get("/communications/{communication_id}", response_class=HTMLResponse)
|
||||
async def communication_detail_page(communication_id: str):
|
||||
item = get_communication(communication_id)
|
||||
if not item:
|
||||
return layout("Comunicação não encontrada", "Inbox classificada", '<section class="cf-empty">Comunicação não encontrada.</section>', "communications")
|
||||
|
||||
action = classification_action(item.get("classification"))
|
||||
confidence = item.get("confidence")
|
||||
confidence_label = "—" if confidence is None else f"{float(confidence):.2f}"
|
||||
|
||||
try:
|
||||
from app.commercial_service import list_customers
|
||||
customers = list_customers(limit=200)
|
||||
except Exception:
|
||||
customers = []
|
||||
try:
|
||||
opportunities = list_opportunities(limit=200, status="open")
|
||||
except Exception:
|
||||
opportunities = []
|
||||
|
||||
customer_options = '<option value="">Sem cliente associado</option>'
|
||||
for c in customers:
|
||||
selected = "selected" if str(c.get("id")) == str(item.get("customer_id")) else ""
|
||||
label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or c.get('email') or 'sem NIF'}"
|
||||
customer_options += f'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
|
||||
|
||||
opportunity_options = '<option value="">Sem oportunidade associada</option>'
|
||||
for o in opportunities:
|
||||
selected = "selected" if str(o.get("id")) == str(item.get("opportunity_id")) else ""
|
||||
label = f"{o.get('title') or 'Oportunidade'} · {o.get('linked_customer_name') or o.get('customer_name') or 'sem cliente'}"
|
||||
opportunity_options += f'<option value="{esc(o.get("id"))}" {selected}>{esc(label)}</option>'
|
||||
|
||||
body = f"""
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/communications">← Voltar a Comunicações</a>
|
||||
<div class="cf-two-col">
|
||||
<main class="d-grid gap-3">
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><div class="text-primary fw-bold">Comunicação</div><h1 class="h4 mb-1">{esc(item.get('subject') or 'Sem assunto')}</h1><div class="text-secondary">{esc(item.get('sender_name') or '')} · {esc(item.get('sender_email') or '—')} · {esc(fmt_dt(item.get('created_at')))}</div></div>{status_badge(item.get('status'))}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3"><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><span class="cf-chip cf-chip-gray">confiança {esc(confidence_label)}</span><span class="cf-chip cf-chip-blue">{esc(action.get('queue'))}</span></div>
|
||||
<div class="cf-copy-box">{esc(item.get('body') or 'Sem corpo guardado.')}</div>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ação sugerida</h2><div class="cf-action-focus"><strong>{esc(action.get('action'))}</strong><div class="small text-secondary mt-1">Esta ação deve aparecer no Centro de trabalho se a comunicação estiver por tratar.</div></div></div></section>
|
||||
</main>
|
||||
<aside class="cf-side">
|
||||
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Associar contexto</h2>
|
||||
<form method="post" action="/communications/{esc(communication_id)}/link-customer" class="d-grid gap-2 mb-3"><label class="form-label small fw-bold">Cliente</label><select class="form-select" name="customer_id">{customer_options}</select><button class="btn btn-outline-primary" type="submit">Guardar cliente</button></form>
|
||||
<form method="post" action="/communications/{esc(communication_id)}/link-opportunity" class="d-grid gap-2"><label class="form-label small fw-bold">Oportunidade</label><select class="form-select" name="opportunity_id">{opportunity_options}</select><button class="btn btn-outline-primary" type="submit">Guardar oportunidade</button></form>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Estado</h2><div class="d-grid gap-2">
|
||||
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="done"><button class="btn btn-success w-100" type="submit">Marcar tratado</button></form>
|
||||
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="needs_review"><button class="btn btn-warning w-100" type="submit">Rever classificação</button></form>
|
||||
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="ignored"><button class="btn btn-outline-secondary w-100" type="submit">Ignorar</button></form>
|
||||
</div></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
"""
|
||||
return layout(str(item.get("subject") or "Comunicação"), "Email/mensagem classificado com contexto", body, "communications")
|
||||
|
||||
|
||||
@router.post("/communications/{communication_id}/status")
|
||||
async def communication_set_status_action(communication_id: str, request: Request):
|
||||
form = await request.form()
|
||||
status = str(form.get("status") or "").strip()
|
||||
try:
|
||||
set_communication_status(communication_id, status)
|
||||
item = get_communication(communication_id)
|
||||
if item and item.get("opportunity_id"):
|
||||
create_timeline_event(
|
||||
opportunity_id=str(item.get("opportunity_id")),
|
||||
customer_id=str(item.get("customer_id") or "") or None,
|
||||
event_type=f"communication_{status}",
|
||||
title=f"Comunicação marcada como {status}",
|
||||
description=str(item.get("subject") or item.get("sender_email") or ""),
|
||||
source="communications",
|
||||
related_type="communication",
|
||||
related_id=communication_id,
|
||||
created_by="operator",
|
||||
)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao atualizar comunicação: {exc}", status_code=500)
|
||||
return RedirectResponse(f"/communications/{communication_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/communications/{communication_id}/link-customer")
|
||||
async def communication_link_customer_action(communication_id: str, request: Request):
|
||||
form = await request.form()
|
||||
customer_id = str(form.get("customer_id") or "").strip()
|
||||
try:
|
||||
link_communication_to_customer(communication_id, customer_id or None)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500)
|
||||
return RedirectResponse(f"/communications/{communication_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/communications/{communication_id}/link-opportunity")
|
||||
async def communication_link_opportunity_action(communication_id: str, request: Request):
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
try:
|
||||
link_communication_to_opportunity(communication_id, opportunity_id or None)
|
||||
if opportunity_id:
|
||||
item = get_communication(communication_id)
|
||||
create_timeline_event(
|
||||
opportunity_id=opportunity_id,
|
||||
customer_id=str(item.get("customer_id") or "") if item else None,
|
||||
event_type="communication_linked",
|
||||
title="Comunicação associada à oportunidade",
|
||||
description=str((item or {}).get("subject") or (item or {}).get("sender_email") or ""),
|
||||
source="communications",
|
||||
related_type="communication",
|
||||
related_id=communication_id,
|
||||
created_by="operator",
|
||||
)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao associar oportunidade: {exc}", status_code=500)
|
||||
return RedirectResponse(f"/communications/{communication_id}", status_code=303)
|
||||
|
||||
|
||||
24
app/admin_ui/pages/conversations.py
Normal file
24
app/admin_ui/pages/conversations.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Conversation placeholder 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/conversations", response_class=HTMLResponse)
|
||||
@router.get("/conversas", response_class=HTMLResponse)
|
||||
async def conversations_page():
|
||||
body = '''
|
||||
<section class="cf-empty">
|
||||
<h2 class="h5">Conversas</h2>
|
||||
<p class="mb-0">A inbox continua no Chatwoot. Esta página fica reservada para a vista consolidada de conversas dentro do ClientFlow.</p>
|
||||
</section>
|
||||
'''
|
||||
return layout("Conversas", "Vista futura de conversas sincronizadas", body, "conversations")
|
||||
|
||||
|
||||
199
app/admin_ui/pages/customers.py
Normal file
199
app/admin_ui/pages/customers.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Customer 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/customers", response_class=HTMLResponse)
|
||||
@router.get("/clientes", response_class=HTMLResponse)
|
||||
async def customers_page(q: Optional[str] = None):
|
||||
try:
|
||||
from app.commercial_service import list_customers
|
||||
customer_rows = list_customers(q=q, limit=300)
|
||||
error = ""
|
||||
except Exception as exc:
|
||||
customer_rows = []
|
||||
error = str(exc)
|
||||
|
||||
rows = ""
|
||||
for c in customer_rows:
|
||||
jasmin_state = "Ligado" if c.get("jasmin_customer_party_key") else "Por validar"
|
||||
jasmin_cls = "cf-chip-green" if c.get("jasmin_customer_party_key") else "cf-chip-orange"
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><a class="cf-row-link" href="/customers/{esc(c.get('id'))}">{esc(c.get('name') or 'Cliente')}</a><div class="small text-secondary">NIF {esc(c.get('tax_id') or '—')}</div></td>
|
||||
<td><div class="text-break">{esc(c.get('email') or '—')}</div><div class="small text-secondary">{esc(c.get('phone') or '')}</div></td>
|
||||
<td>{esc(c.get('city_name') or '—')}<div class="small text-secondary">{esc(c.get('postal_zone') or '')}</div></td>
|
||||
<td><span class="cf-chip {jasmin_cls}">{esc(jasmin_state)}</span><div class="small text-secondary"><code>{esc(c.get('jasmin_customer_party_key') or '')}</code></div></td>
|
||||
<td><span class="cf-chip cf-chip-blue">{int(c.get('opportunity_count') or 0)} oportunidades</span></td>
|
||||
<td>{esc(fmt_dt(c.get('updated_at')))}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem clientes locais. Cria uma ficha ou associa a partir de uma oportunidade.</td></tr>'
|
||||
|
||||
error_html = f'<div class="alert alert-warning">{esc(error)}</div>' if error else ''
|
||||
body = f"""
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-lg-8">
|
||||
<section class="card cf-card cf-filter-card">
|
||||
<form method="get" action="/customers" class="row g-3 align-items-end">
|
||||
<div class="col-lg-8"><label class="form-label small fw-bold text-secondary">Procurar cliente</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="nome, NIF, email, telefone..."></div>
|
||||
<div class="col-lg-4 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/customers">Limpar</a></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-2">Novo cliente</h2><form method="post" action="/customers/create" class="d-grid gap-2"><input class="form-control" name="name" placeholder="Nome fiscal" required><input class="form-control" name="tax_id" placeholder="NIF"><button class="btn btn-primary" type="submit">Criar ficha</button></form></div></section>
|
||||
</div>
|
||||
</div>
|
||||
{error_html}
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Clientes</h2><div class="small text-secondary">Dados fiscais e moradas vivem aqui. A oportunidade mostra só o estado da compra.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Cliente</th><th>Contactos</th><th>Localidade</th><th>Jasmin</th><th>Pipeline</th><th>Atualizado</th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
|
||||
"""
|
||||
return layout("Clientes", "Ficha fiscal, contactos e documentos por cliente", body, "customers")
|
||||
|
||||
|
||||
@router.post("/customers/create")
|
||||
async def create_customer_action(request: Request):
|
||||
form = await request.form()
|
||||
try:
|
||||
from app.commercial_service import upsert_customer
|
||||
customer = upsert_customer({
|
||||
"name": str(form.get("name") or "").strip(),
|
||||
"tax_id": str(form.get("tax_id") or "").strip(),
|
||||
"email": str(form.get("email") or "").strip(),
|
||||
"phone": str(form.get("phone") or "").strip(),
|
||||
})
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao criar cliente: {exc}", status_code=500)
|
||||
return RedirectResponse(f"/customers/{customer.get('id')}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}", response_class=HTMLResponse)
|
||||
async def customer_detail_page(customer_id: str):
|
||||
try:
|
||||
from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments
|
||||
customer = get_customer(customer_id)
|
||||
if not customer:
|
||||
return layout("Cliente não encontrado", "Clientes", '<section class="cf-empty">Cliente não encontrado.</section>', "customers")
|
||||
docs = list_commercial_documents(customer_id=customer_id, limit=100)
|
||||
opps = list_opportunities_for_customer(customer_id, limit=50)
|
||||
shipments = list_shipments(customer_id=customer_id, limit=50)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao abrir cliente: {exc}", status_code=500)
|
||||
|
||||
doc_rows = ""
|
||||
for d in docs:
|
||||
number = d.get("document_number") or " ".join([str(d.get("document_type") or ""), str(d.get("serie") or ""), str(d.get("series_number") or "")]).strip() or d.get("external_id") or "—"
|
||||
kind = {"quotation": "Orçamento", "invoice": "Fatura"}.get(str(d.get("document_kind") or ""), d.get("document_kind") or "Documento")
|
||||
doc_rows += f"<tr><td>{esc(kind)}</td><td><code>{esc(number)}</code></td><td>{operation_status_badge(str(d.get('status') or 'created'))}</td><td>{money_html(d.get('total_amount') or d.get('amount') or 0)}</td><td>{esc(fmt_dt(d.get('created_at')))}</td></tr>"
|
||||
if not doc_rows:
|
||||
doc_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem documentos Jasmin locais.</td></tr>'
|
||||
|
||||
opp_rows = ""
|
||||
for o in opps:
|
||||
opp_rows += f"<tr><td><a class='cf-row-link' href='/opportunities/{esc(o.get('id'))}'>{esc(o.get('title') or 'Oportunidade')}</a><div class='small text-secondary'>{esc(o.get('product_interest') or '')}</div></td><td>{opportunity_stage_badge(o.get('stage'))}</td><td>{money_html(o.get('value_amount') or 0)}</td><td>{esc(fmt_dt(o.get('updated_at')))}</td></tr>"
|
||||
if not opp_rows:
|
||||
opp_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem oportunidades associadas.</td></tr>'
|
||||
|
||||
shipment_rows = ""
|
||||
for sh in shipments:
|
||||
shipment_rows += f"<tr><td>{esc(sh.get('carrier') or '—')}</td><td>{esc(sh.get('service_name') or '—')}</td><td>{operation_status_badge(str(sh.get('status') or 'created'))}</td><td><code>{esc(sh.get('external_reference') or '')}</code></td><td>{esc(sh.get('tracking_code') or '—')}</td></tr>"
|
||||
if not shipment_rows:
|
||||
shipment_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem envios Packlink locais.</td></tr>'
|
||||
|
||||
body = f"""
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-5">
|
||||
<section class="card cf-card"><div class="card-body p-4"><h1 class="h4 fw-bold mb-1">{esc(customer.get('name'))}</h1><div class="text-secondary mb-3">NIF {esc(customer.get('tax_id') or '—')}</div><form method="post" action="/customers/{esc(customer_id)}/update" class="row g-3">
|
||||
<div class="col-12"><label class="form-label small fw-bold text-secondary">Nome fiscal</label><input class="form-control" name="name" value="{esc(customer.get('name') or '')}" required></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">NIF</label><input class="form-control" name="tax_id" value="{esc(customer.get('tax_id') or '')}"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">País</label><input class="form-control" name="country" value="{esc(customer.get('country') or 'PT')}"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Email</label><input class="form-control" name="email" value="{esc(customer.get('email') or '')}"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" value="{esc(customer.get('phone') or '')}"></div>
|
||||
<div class="col-12"><label class="form-label small fw-bold text-secondary">Morada fiscal</label><input class="form-control" name="street_name" value="{esc(customer.get('street_name') or '')}"></div>
|
||||
<div class="col-md-5"><label class="form-label small fw-bold text-secondary">Código postal</label><input class="form-control" name="postal_zone" value="{esc(customer.get('postal_zone') or '')}"></div>
|
||||
<div class="col-md-7"><label class="form-label small fw-bold text-secondary">Cidade</label><input class="form-control" name="city_name" value="{esc(customer.get('city_name') or '')}"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin partyKey</label><input class="form-control" name="jasmin_customer_party_key" value="{esc(customer.get('jasmin_customer_party_key') or '')}"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin ID</label><input class="form-control" name="jasmin_customer_id" value="{esc(customer.get('jasmin_customer_id') or '')}"></div>
|
||||
<div class="col-12"><button class="btn btn-primary" type="submit">Guardar cliente</button></div>
|
||||
</form></div></section>
|
||||
</div>
|
||||
<div class="col-lg-7 d-grid gap-3">
|
||||
<section class="card cf-card"><div class="card-body p-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Nova oportunidade</h2><div class="small text-secondary">Cria um processo comercial manual já ligado a este cliente fiscal.</div></div></div>
|
||||
<form method="post" action="/customers/{esc(customer_id)}/opportunities/create" class="row g-2 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Origem</label><select class="form-select" name="origin"><option value="phone">Telefone</option><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="presential">Presencial</option><option value="manual">Manual</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="proforma">Pró-forma</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Ex.: carregador monofásico, cabo, instalação..."></div>
|
||||
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Contacto</label><input class="form-control" name="contact_name" placeholder="Nome do contacto"></div>
|
||||
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Email contacto</label><input class="form-control" name="contact_email" placeholder="email@empresa.pt"></div>
|
||||
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Telefone contacto</label><input class="form-control" name="contact_phone" placeholder="telefone"></div>
|
||||
<div class="col-12"><label class="form-label small fw-bold text-secondary">Notas</label><textarea class="form-control" name="notes" rows="2" placeholder="Resumo do pedido, contexto da chamada ou próximos passos"></textarea></div>
|
||||
<div class="col-md-8"><div class="form-check"><input class="form-check-input" type="checkbox" name="create_task" value="1" id="create-task-from-customer" checked><label class="form-check-label small" for="create-task-from-customer">Criar tarefa inicial para a próxima ação</label></div></div>
|
||||
<div class="col-md-4 d-grid"><button class="btn btn-primary" type="submit">Criar oportunidade</button></div>
|
||||
</form>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Estado</th><th>Valor</th><th>Atualizada</th></tr></thead><tbody>{opp_rows}</tbody></table></div></div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Documentos Jasmin</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Número/ID</th><th>Estado</th><th>Valor</th><th>Criado</th></tr></thead><tbody>{doc_rows}</tbody></table></div></div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Envios Packlink</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Transportadora</th><th>Serviço</th><th>Estado</th><th>Referência</th><th>Tracking</th></tr></thead><tbody>{shipment_rows}</tbody></table></div></div></section>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return layout(str(customer.get("name") or "Cliente"), "Ficha fiscal, oportunidades e documentos", body, "customers")
|
||||
|
||||
|
||||
@router.post("/customers/{customer_id}/opportunities/create")
|
||||
async def create_customer_opportunity_action(customer_id: str, request: Request):
|
||||
form = await request.form()
|
||||
try:
|
||||
from app.opportunity_service import create_manual_opportunity_from_customer
|
||||
result = create_manual_opportunity_from_customer(
|
||||
customer_id,
|
||||
origin=str(form.get("origin") or "phone").strip(),
|
||||
request_type=str(form.get("request_type") or "quote").strip(),
|
||||
contact_name=str(form.get("contact_name") or "").strip(),
|
||||
contact_email=str(form.get("contact_email") or "").strip(),
|
||||
contact_phone=str(form.get("contact_phone") or "").strip(),
|
||||
product_interest=str(form.get("product_interest") or "").strip(),
|
||||
notes=str(form.get("notes") or "").strip(),
|
||||
create_task=bool(form.get("create_task")),
|
||||
created_by="operator",
|
||||
)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao criar oportunidade: {exc}", status_code=500)
|
||||
return RedirectResponse(result.get("next_url") or f"/customers/{customer_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/customers/{customer_id}/update")
|
||||
async def update_customer_action(customer_id: str, request: Request):
|
||||
form = await request.form()
|
||||
try:
|
||||
from app.commercial_service import update_customer
|
||||
update_customer(customer_id, {
|
||||
"name": str(form.get("name") or "").strip(),
|
||||
"tax_id": str(form.get("tax_id") or "").strip(),
|
||||
"email": str(form.get("email") or "").strip(),
|
||||
"phone": str(form.get("phone") or "").strip(),
|
||||
"street_name": str(form.get("street_name") or "").strip(),
|
||||
"postal_zone": str(form.get("postal_zone") or "").strip(),
|
||||
"city_name": str(form.get("city_name") or "").strip(),
|
||||
"country": str(form.get("country") or "PT").strip(),
|
||||
"jasmin_customer_party_key": str(form.get("jasmin_customer_party_key") or "").strip(),
|
||||
"jasmin_customer_id": str(form.get("jasmin_customer_id") or "").strip(),
|
||||
})
|
||||
except Exception as exc:
|
||||
# Keep database details out of the operator UI. Duplicate NIFs are a
|
||||
# business conflict, not a technical 500.
|
||||
status = 409 if exc.__class__.__name__ == "DuplicateCustomerTaxIdError" else 500
|
||||
return PlainTextResponse(f"Erro ao guardar cliente: {exc}", status_code=status)
|
||||
return RedirectResponse(f"/customers/{customer_id}", status_code=303)
|
||||
|
||||
|
||||
102
app/admin_ui/pages/dashboard.py
Normal file
102
app/admin_ui/pages/dashboard.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Dashboard and landing 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home():
|
||||
"""v4.5 clean Dashboard: visibility, not daily execution."""
|
||||
metrics = get_admin_dashboard_metrics()
|
||||
ops = get_operations_summary(limit=6)
|
||||
counts = ops.get("counts") or {}
|
||||
comms = get_communications_summary()
|
||||
|
||||
def n(key: str) -> int:
|
||||
return int(metrics.get(key) or counts.get(key) or 0)
|
||||
|
||||
dashboard_cards = [
|
||||
("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", "Negócio em acompanhamento"),
|
||||
("Valor / documentos", counts.get("open_quotations", 0), "/finance", "Orçamentos abertos"),
|
||||
("Tasks pendentes", n("pending_total"), "/operations", "Trabalho humano por resolver"),
|
||||
("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"),
|
||||
("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin/Packlink/outbox"),
|
||||
("Pagamentos por confirmar", n("pending_financeiro"), "/operations", "Fila financeira"),
|
||||
("Envios pendentes", counts.get("shipments_pending", 0), "/orders", "Logística/Packlink"),
|
||||
("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Dados fiscais/morada"),
|
||||
]
|
||||
|
||||
cards_html = ""
|
||||
for label, value, href, hint in dashboard_cards:
|
||||
cards_html += kpi_card(label, value, href, hint)
|
||||
|
||||
alert_items = []
|
||||
if int(counts.get("outbox_failed") or 0):
|
||||
alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas na outbox", "/outbox?status=failed", "cf-chip-red"))
|
||||
if int(comms.get("needs_review") or 0):
|
||||
alert_items.append(("Rever comunicação", f"{comms.get('needs_review')} mensagem(ns) com baixa confiança", "/operations", "cf-chip-orange"))
|
||||
if int(counts.get("customers_incomplete") or 0):
|
||||
alert_items.append(("Dados incompletos", f"{counts.get('customers_incomplete')} cliente(s) sem dados fiscais/morada completos", "/customers", "cf-chip-orange"))
|
||||
if int(counts.get("products_missing_jasmin") or 0):
|
||||
alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) ativos sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red"))
|
||||
|
||||
alert_html = ""
|
||||
for title, detail, href, chip in alert_items[:5]:
|
||||
alert_html += f"""
|
||||
<a class="d-flex justify-content-between align-items-start gap-3 py-3 border-bottom text-reset" href="{esc(href)}">
|
||||
<div><span class="cf-chip {esc(chip)} mb-2">{esc(title)}</span><div class="fw-bold">{esc(detail)}</div></div>
|
||||
<span class="text-primary fw-bold">Abrir →</span>
|
||||
</a>
|
||||
"""
|
||||
if not alert_html:
|
||||
alert_html = '<div class="text-secondary py-3">Sem alertas críticos neste momento.</div>'
|
||||
|
||||
body = f"""
|
||||
<section class="alert alert-primary border-0 shadow-sm d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<strong>Dashboard = visibilidade.</strong>
|
||||
<span class="ms-1">O Chatwoot é a inbox. O ClientFlow mostra o trabalho, bloqueios e próximas ações.</span>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/operations">Abrir Centro de trabalho</a>
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{cards_html}
|
||||
</section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-7">
|
||||
<section class="card cf-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div><h2 class="cf-section-title">Alertas</h2><div class="small text-secondary">Sinais globais que merecem atenção.</div></div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/operations">Resolver no Centro de trabalho</a>
|
||||
</div>
|
||||
{alert_html}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<section class="card cf-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Modelo operacional v4.5</h2>
|
||||
<div class="d-grid gap-3">
|
||||
<div class="cf-soft-box"><strong>Dashboard</strong><div class="small text-secondary">Mostra o estado e gargalos.</div></div>
|
||||
<div class="cf-soft-box"><strong>Centro de trabalho</strong><div class="small text-secondary">Organiza o que precisa de ação agora.</div></div>
|
||||
<div class="cf-soft-box"><strong>Chatwoot → ClientFlow</strong><div class="small text-secondary">O Chatwoot continua a ser a inbox; o ClientFlow transforma mensagens em ações, tasks e timeline.</div></div>
|
||||
<div class="cf-soft-box"><strong>Oportunidade</strong><div class="small text-secondary">Mantém contexto, documentos, tasks, outbox e timeline.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return layout("Dashboard", "Visão geral do negócio e do sistema", body, "overview")
|
||||
|
||||
|
||||
46
app/admin_ui/pages/events.py
Normal file
46
app/admin_ui/pages/events.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Business event 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/events", response_class=HTMLResponse)
|
||||
async def events_page(limit: int = 100):
|
||||
events = list_business_events(limit=limit)
|
||||
|
||||
rows = ""
|
||||
for event in events:
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td>{esc(event["created_at"])}</td>
|
||||
<td>{esc(event.get("event_type"))}</td>
|
||||
<td>#{esc(event.get("conversation_id"))}</td>
|
||||
<td><small>{esc(event.get("task_id"))}</small></td>
|
||||
<td>{esc(event.get("created_by"))}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
body = f"""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Criado</th>
|
||||
<th>Evento</th>
|
||||
<th>Conversa</th>
|
||||
<th>Task</th>
|
||||
<th>Criado por</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return layout("Business Events", "Eventos criados após conclusão de tarefas.", body, "events")
|
||||
|
||||
|
||||
51
app/admin_ui/pages/finance.py
Normal file
51
app/admin_ui/pages/finance.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Finance 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/finance", response_class=HTMLResponse)
|
||||
@router.get("/financeiro", response_class=HTMLResponse)
|
||||
async def finance_page(q: Optional[str] = None):
|
||||
finance_tasks = list_tasks(status=None, route="financeiro", q=q, limit=200)
|
||||
finance_actions = {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT"}
|
||||
finance_tasks = [t for t in finance_tasks if str(t.get("action_code") or "") in finance_actions or str(t.get("route") or "") == "financeiro"]
|
||||
opportunities = list_opportunities(status="all", q=q, limit=300)
|
||||
payment_opps = [o for o in opportunities if str(o.get("stage") or "") in {"PROFORMA_REQUESTED", "PROFORMA_SENT", "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED"}]
|
||||
|
||||
rows = ""
|
||||
for task in finance_tasks[:80]:
|
||||
tid = str(task.get("id") or "")
|
||||
customer = customer_display(task)
|
||||
code = str(task.get("action_code") or "")
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><a class="cf-row-link" href="/tasks/{esc(tid)}">{esc(action_label(code))}</a><div class="small"><code>{esc(code)}</code></div></td>
|
||||
<td><strong>{esc(customer)}</strong><div class="small text-secondary text-break">{esc(task.get('customer_email') or '')}</div></td>
|
||||
<td>{status_badge(task.get('status'))}</td>
|
||||
<td>{esc(task.get('created_at') or '—')}</td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(tid)}">Abrir</a></td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="5" class="text-center text-secondary py-5">Sem tarefas financeiras.</td></tr>'
|
||||
|
||||
body = f"""
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Tarefas financeiras', len(finance_tasks), '/finance', 'ativas/histórico', 'bi-list-check')}
|
||||
{kpi_card('A aguardar pagamento', sum(1 for o in payment_opps if str(o.get('stage')) == 'WAITING_PAYMENT'), '/opportunities', 'oportunidades', 'bi-hourglass-split', 'cf-kpi-tone-orange')}
|
||||
{kpi_card('Pendentes', sum(1 for t in finance_tasks if str(t.get('status')) == 'pending'), '/tasks?status=pending&route=financeiro', 'abrir tarefas', 'bi-list-check')}
|
||||
{kpi_card('Pagamentos confirmados', sum(1 for o in payment_opps if str(o.get('stage')) == 'PAYMENT_CONFIRMED'), '/opportunities', 'seguir para envio', 'bi-check2-circle', 'cf-kpi-tone-green')}
|
||||
</section>
|
||||
<section class="card cf-card cf-filter-card"><form method="get" action="/finance" class="row g-3 align-items-end"><div class="col-lg-8"><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, fatura, pagamento..."></div><div class="col-lg-4 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/finance">Limpar</a></div></form></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Financeiro operacional</h2><div class="small text-secondary">Pró-formas, faturas e pagamentos a tratar.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Cliente</th><th>Estado</th><th>Criada</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
|
||||
"""
|
||||
return layout("Financeiro", "O que falta faturar ou confirmar?", body, "finance")
|
||||
|
||||
|
||||
198
app/admin_ui/pages/integrations.py
Normal file
198
app/admin_ui/pages/integrations.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Integration configuration and 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/integrations", response_class=HTMLResponse)
|
||||
@router.get("/integracoes", response_class=HTMLResponse)
|
||||
async def integrations_page():
|
||||
odoo_stats = {}
|
||||
odoo_last_sync = "—"
|
||||
odoo_error = ""
|
||||
|
||||
try:
|
||||
snapshot = get_odoo_product_snapshot(limit=1)
|
||||
odoo_stats = snapshot.get("stats") or {}
|
||||
odoo_last_sync = odoo_stats.get("last_synced_at") or "—"
|
||||
except Exception as exc:
|
||||
odoo_error = str(exc)
|
||||
|
||||
def badge(enabled: bool) -> str:
|
||||
if enabled:
|
||||
return '<span class="badge bg-success-subtle text-success">Ativo</span>'
|
||||
return '<span class="badge bg-secondary-subtle text-secondary">Inativo</span>'
|
||||
|
||||
odoo_enabled = bool(getattr(settings, "odoo_enabled", False))
|
||||
jasmin_enabled = bool(getattr(settings, "jasmin_enabled", False))
|
||||
packlink_enabled = bool(getattr(settings, "packlink_enabled", False))
|
||||
chatwoot_enabled = bool(getattr(settings, "chatwoot_base_url", ""))
|
||||
|
||||
odoo_error_html = ""
|
||||
if odoo_error:
|
||||
odoo_error_html = f"""
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<strong>Erro Odoo:</strong> {esc(odoo_error)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
body = f"""
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<div class="cf-eyebrow">Configuração</div>
|
||||
<h1 class="h3 mb-1">Integrações</h1>
|
||||
<p class="text-secondary mb-0">Ligações externas usadas pelo cockpit operacional.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="cf-section-title mb-1">Odoo</h2>
|
||||
<div class="small text-secondary">Produtos vendáveis, stock e estado operacional.</div>
|
||||
</div>
|
||||
{badge(odoo_enabled)}
|
||||
</div>
|
||||
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-secondary">Base URL</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "odoo_base_url", "") or "—")}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">Base de dados</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "odoo_db", "") or "—")}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">Produtos sincronizados</dt>
|
||||
<dd class="col-7">{esc(odoo_stats.get("synced_products") or 0)}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">Com BOM</dt>
|
||||
<dd class="col-7">{esc(odoo_stats.get("products_with_bom") or 0)}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">Com stock disponível</dt>
|
||||
<dd class="col-7">{esc(odoo_stats.get("products_with_available_stock") or 0)}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">Último sync</dt>
|
||||
<dd class="col-7">{esc(odoo_last_sync)}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post" action="/integrations/odoo/test">
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Testar ligação</button>
|
||||
</form>
|
||||
<form method="post" action="/integrations/odoo/sync-products">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Sincronizar produtos vendáveis</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{odoo_error_html}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="cf-section-title mb-1">Jasmin</h2>
|
||||
<div class="small text-secondary">Pró-formas, faturas e documentos fiscais.</div>
|
||||
</div>
|
||||
{badge(jasmin_enabled)}
|
||||
</div>
|
||||
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5 text-secondary">Base URL</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "jasmin_base_url", "") or "—")}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">URL público</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "jasmin_public_url", "") or "—")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="cf-section-title mb-1">Packlink PRO</h2>
|
||||
<div class="small text-secondary">Recolhas, envios, etiquetas e tracking.</div>
|
||||
</div>
|
||||
{badge(packlink_enabled)}
|
||||
</div>
|
||||
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5 text-secondary">Base URL</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "packlink_base_url", "") or "—")}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">URL público</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "packlink_public_url", "") or "—")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="cf-section-title mb-1">Chatwoot</h2>
|
||||
<div class="small text-secondary">Comunicação com cliente.</div>
|
||||
</div>
|
||||
{badge(chatwoot_enabled)}
|
||||
</div>
|
||||
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5 text-secondary">Base URL</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "chatwoot_base_url", "") or "—")}</dd>
|
||||
|
||||
<dt class="col-5 text-secondary">URL público</dt>
|
||||
<dd class="col-7">{esc(getattr(settings, "chatwoot_public_url", "") or "—")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card cf-card mt-4">
|
||||
<div class="card-body">
|
||||
<h2 class="cf-section-title mb-2">Regra operacional</h2>
|
||||
<p class="text-secondary mb-0">
|
||||
O ClientFlow não replica Odoo, Jasmin ou Packlink. Apenas consulta e guarda o estado necessário
|
||||
para decidir a próxima ação com o cliente.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
return layout("Integrações", "Configuração das ligações externas", body, "integrations")
|
||||
|
||||
|
||||
@router.post("/integrations/odoo/test")
|
||||
async def integrations_odoo_test_action(request: Request):
|
||||
try:
|
||||
test_odoo_connection()
|
||||
except Exception:
|
||||
pass
|
||||
return RedirectResponse(url="/integrations", status_code=303)
|
||||
|
||||
|
||||
@router.post("/integrations/odoo/sync-products")
|
||||
async def integrations_odoo_sync_products_action(request: Request):
|
||||
try:
|
||||
sync_odoo_products(limit=1000, include_inactive=True)
|
||||
except Exception:
|
||||
pass
|
||||
return RedirectResponse(url="/integrations", status_code=303)
|
||||
|
||||
|
||||
185
app/admin_ui/pages/operations.py
Normal file
185
app/admin_ui/pages/operations.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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) -> 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"
|
||||
status_chip_html = "" if status_text == "normal" else f'<span class="cf-chip {priority_chip}">{esc(status_text)}</span>'
|
||||
primary = operation_primary_label(item)
|
||||
# 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-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>
|
||||
{blockers_html}
|
||||
<div class="cf-work-actions">
|
||||
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(item.get('href') or '#')}">{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]) -> str:
|
||||
if not items:
|
||||
return ""
|
||||
cards = "".join(_render_work_item(item) 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:
|
||||
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or []) + _render_work_group("Normal", model.get("normal_items") or [])
|
||||
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")
|
||||
1282
app/admin_ui/pages/opportunities.py
Normal file
1282
app/admin_ui/pages/opportunities.py
Normal file
File diff suppressed because it is too large
Load Diff
163
app/admin_ui/pages/orders.py
Normal file
163
app/admin_ui/pages/orders.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Order and fulfilment 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/orders", response_class=HTMLResponse)
|
||||
@router.get("/encomendas", response_class=HTMLResponse)
|
||||
async def orders_page(status: Optional[str] = None, q: Optional[str] = None):
|
||||
opportunities = list_opportunities(status="all", q=q, limit=300)
|
||||
order_stages = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION", "SHIPPED", "WON"}
|
||||
if status:
|
||||
if status == "preparing":
|
||||
stages = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION"}
|
||||
elif status == "shipped":
|
||||
stages = {"SHIPPED", "WON"}
|
||||
else:
|
||||
stages = order_stages
|
||||
else:
|
||||
stages = order_stages
|
||||
orders = [opp for opp in opportunities if str(opp.get("stage") or "") in stages]
|
||||
|
||||
rows = ""
|
||||
for opp in orders:
|
||||
oid = str(opp.get("id") or "")
|
||||
stage = str(opp.get("stage") or "")
|
||||
customer = opp.get("customer_name") or opp.get("customer_email") or "Cliente"
|
||||
product = opp.get("product_interest") or opp.get("title") or "—"
|
||||
state_chip = opportunity_stage_badge(stage)
|
||||
next_step = {
|
||||
"PAYMENT_CONFIRMED": "Criar/preparar encomenda",
|
||||
"ORDER_PREPARATION": "Preparar material e envio",
|
||||
"SHIPPED": "Acompanhar entrega",
|
||||
"WON": "Concluída",
|
||||
"NO_INTEREST": "Sem interesse",
|
||||
}.get(stage, "Acompanhar")
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><a class="cf-row-link" href="/orders/{esc(oid)}">#{esc(oid[:8])}</a></td>
|
||||
<td><strong>{esc(customer)}</strong><div class="small text-secondary text-break">{esc(opp.get('customer_email') or '')}</div></td>
|
||||
<td>{esc(product)}</td>
|
||||
<td>{state_chip}</td>
|
||||
<td class="text-secondary">{esc(next_step)}</td>
|
||||
<td>{esc(opp.get('updated_at') or '—')}</td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/orders/{esc(oid)}">Abrir</a></td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Ainda não existem encomendas em preparação/envio.</td></tr>'
|
||||
|
||||
body = f"""
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('A preparar', sum(1 for o in opportunities if str(o.get('stage')) in {'PAYMENT_CONFIRMED','ORDER_PREPARATION'}), '/orders', 'pagas/preparação', 'bi-box-seam')}
|
||||
{kpi_card('Enviadas', sum(1 for o in opportunities if str(o.get('stage')) == 'SHIPPED'), '/orders?status=shipped', 'em trânsito', 'bi-truck', 'cf-kpi-tone-green')}
|
||||
{kpi_card('Pipeline', len(opportunities), '/opportunities', 'oportunidades', 'bi-funnel')}
|
||||
{kpi_card('Tarefas operações', '→', '/tasks?status=pending&route=operacoes', 'abrir fila', 'bi-list-check')}
|
||||
</section>
|
||||
|
||||
<section class="card cf-card cf-filter-card">
|
||||
<form class="row g-3 align-items-end" method="get" action="/orders">
|
||||
<div class="col-lg-6">
|
||||
<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, produto, email...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold text-secondary">Estado</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="" {'' if status else 'selected'}>Todas</option>
|
||||
<option value="preparing" {'selected' if status == 'preparing' else ''}>A preparar</option>
|
||||
<option value="shipped" {'selected' if status == 'shipped' else ''}>Enviadas</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="/orders">Limpar</a></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom"><h2 class="cf-section-title">Encomendas e preparação</h2><div class="small text-secondary">Derivado das oportunidades com pagamento confirmado/preparação/envio.</div></div>
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table">
|
||||
<thead><tr><th>Encomenda</th><th>Cliente</th><th>Produto</th><th>Estado</th><th>Próximo passo</th><th>Atualizada</th><th></th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
return layout("Encomendas", "O que falta preparar, enviar ou acompanhar?", body, "orders")
|
||||
|
||||
|
||||
@router.get("/orders/{opportunity_id}", response_class=HTMLResponse)
|
||||
async def order_detail_page(opportunity_id: str):
|
||||
opp = get_opportunity(opportunity_id)
|
||||
if not opp:
|
||||
return layout("Encomenda não encontrada", "Encomendas", '<section class="cf-empty">Encomenda não encontrada.</section>', "orders")
|
||||
|
||||
stage = str(opp.get("stage") or "")
|
||||
tasks = list_opportunity_tasks(opportunity_id, limit=20)
|
||||
opportunity_items = list_opportunity_items(opportunity_id)
|
||||
material_rows = ""
|
||||
for item in opportunity_items:
|
||||
material_rows += f"""
|
||||
<tr>
|
||||
<td><strong>{esc(item.get('product_name') or 'Produto')}</strong><div class="small text-secondary">SKU/Odoo <code>{esc(item.get('sku') or '—')}</code></div><div class="small text-secondary">Jasmin <code>{esc(item.get('jasmin_sales_item') or '—')}</code></div></td>
|
||||
<td class="text-end">{esc(item.get('quantity') or '1')}</td>
|
||||
<td class="text-end">{money_html(item.get('unit_price'))}</td>
|
||||
<td>{item_status_badge(item.get('status'))}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not material_rows:
|
||||
material_rows = f'<tr><td colspan="4" class="text-center text-secondary py-4">Sem produtos definidos. <a href="/opportunities/{esc(opportunity_id)}">Adicionar na oportunidade</a>.</td></tr>'
|
||||
|
||||
task_rows = ""
|
||||
for task in tasks:
|
||||
task_rows += f'<li><a class="cf-row-link" href="/tasks/{esc(task.get("id"))}">{esc(action_label(task.get("action_code")))}</a> · {status_badge(task.get("status"))}</li>'
|
||||
if not task_rows:
|
||||
task_rows = '<li class="text-secondary">Sem tarefas associadas.</li>'
|
||||
|
||||
body = f"""
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/orders">← Voltar a encomendas</a>
|
||||
<div class="cf-two-col">
|
||||
<div class="d-grid gap-3">
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<span class="text-secondary small fw-bold text-uppercase">Encomenda</span>
|
||||
<h2 class="h3 fw-bold mb-2">{esc(opp.get('title') or 'Encomenda')}</h2>
|
||||
<div class="d-flex flex-wrap gap-2">{opportunity_stage_badge(stage)} <a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a></div>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0">
|
||||
<div class="p-3 border-bottom"><h2 class="cf-section-title">Material</h2><div class="small text-secondary">Produtos aceites/em preparação derivados da oportunidade.</div></div>
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table">
|
||||
<thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th>Estado</th></tr></thead>
|
||||
<tbody>{material_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Checklist</h2>
|
||||
<div class="vstack gap-2">
|
||||
<label><input type="checkbox" class="form-check-input me-2">Confirmar stock</label>
|
||||
<label><input type="checkbox" class="form-check-input me-2">Preparar material</label>
|
||||
<label><input type="checkbox" class="form-check-input me-2">Embalar</label>
|
||||
<label><input type="checkbox" class="form-check-input me-2">Enviar tracking ao cliente</label>
|
||||
</div>
|
||||
</div></section>
|
||||
</div>
|
||||
<aside class="cf-side">
|
||||
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Cliente</h2><strong>{esc(opp.get('customer_name') or 'Cliente')}</strong><div class="small text-secondary text-break">{esc(opp.get('customer_email') or '')}</div></div></section>
|
||||
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Ações</h2>{opportunity_quick_actions_html(opportunity_id)}</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Tarefas</h2><ul class="mb-0 ps-3">{task_rows}</ul></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
"""
|
||||
return layout(str(opp.get("title") or "Encomenda"), "Preparação e envio", body, "orders")
|
||||
|
||||
|
||||
242
app/admin_ui/pages/outbox.py
Normal file
242
app/admin_ui/pages/outbox.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Integration outbox routes and actions.
|
||||
|
||||
Moved from app.admin_dashboard in v4.7.2. v4.7.4 adds a dedicated
|
||||
HTMX table partial so filters and operator actions can refresh the outbox
|
||||
without replacing the full page.
|
||||
"""
|
||||
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 outbox_operator_message
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _outbox_tab_url(status: str = "all", target_system: str = "all") -> str:
|
||||
parts = []
|
||||
if status and status != "all":
|
||||
parts.append(f"status={esc(status)}")
|
||||
if target_system and target_system != "all":
|
||||
parts.append(f"target_system={esc(target_system)}")
|
||||
return "/outbox" + ("?" + "&".join(parts) if parts else "")
|
||||
|
||||
|
||||
def _outbox_partial_url(status: str = "all", target_system: str = "all") -> str:
|
||||
href = _outbox_tab_url(status=status, target_system=target_system)
|
||||
return "/outbox/partials/table" + (href[href.find("?"):] if "?" in href else "")
|
||||
|
||||
|
||||
def _outbox_items(status: str = "all", target_system: str = "all", limit: int = 100) -> list[dict]:
|
||||
query_status = None if status == "all" else status
|
||||
query_target = None if target_system == "all" else target_system
|
||||
return list_outbox(status=query_status, target_system=query_target, limit=limit)
|
||||
|
||||
|
||||
def render_outbox_table_partial(items: list[dict], status: str = "all", target_system: str = "all") -> str:
|
||||
rows = ""
|
||||
for item in items:
|
||||
payload = item.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except Exception:
|
||||
payload = {}
|
||||
opp_id = payload.get("opportunity_id") or ""
|
||||
conv = payload.get("conversation_id") or ""
|
||||
err = str(item.get("last_error") or "")
|
||||
human_error = outbox_operator_message(item)
|
||||
short_err = err[:220] + "…" if len(err) > 220 else err
|
||||
origin = f'<a href="/opportunities/{esc(opp_id)}">Oportunidade</a>' if opp_id else '—'
|
||||
conv_html = f'Conversa #{esc(conv)}' if conv else ''
|
||||
item_id = str(item.get("id") or "")
|
||||
oid = esc(item_id)
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td><div class="small text-secondary">{esc(fmt_dt(item.get('created_at')))}</div><code>{oid}</code></td>
|
||||
<td>{operation_status_badge(str(item.get('status') or 'pending'))}<div class="small text-secondary">{esc(fmt_dt(item.get('locked_at')) if item.get('locked_at') else '')}</div><div class="small text-secondary text-break">{esc(item.get('lock_owner') or '')}</div></td>
|
||||
<td><strong>{esc(item.get('target_system'))}</strong><div class="small text-secondary"><code>{esc(item.get('action_type'))}</code></div></td>
|
||||
<td>{origin}<div class="small text-secondary">{conv_html}</div></td>
|
||||
<td class="small text-break">
|
||||
<div class="cf-outbox-human-error">
|
||||
<strong>{esc(human_error.get('title'))}</strong>
|
||||
<div class="small">Motivo provável: {esc(human_error.get('probable'))}</div>
|
||||
{f'<div class="small text-danger mt-1">Técnico: {esc(short_err)}</div>' if short_err else ''}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex flex-wrap justify-content-end gap-1">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/outbox/{oid}">Ver</a>
|
||||
<form method="post" action="/outbox/{oid}/pending" hx-post="/outbox/{oid}/pending" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button></form>
|
||||
<form method="post" action="/outbox/{oid}/ignored" hx-post="/outbox/{oid}/ignored" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
'''
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem itens de outbox para os filtros selecionados.</td></tr>'
|
||||
|
||||
return f'''
|
||||
<div id="outbox-table" 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(items)} resultado(s)</span>
|
||||
<span class="small text-secondary htmx-indicator" id="outbox-loading">A atualizar…</span>
|
||||
</div>
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table">
|
||||
<thead><tr><th>Criado / ID</th><th>Estado</th><th>Sistema / Ação</th><th>Origem</th><th>Erro</th><th class="text-end">Ações</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
@router.get("/outbox/partials/table", response_class=HTMLResponse)
|
||||
async def outbox_table_partial(status: Optional[str] = None, target_system: Optional[str] = None, limit: int = 100):
|
||||
status = status or "all"
|
||||
target_system = target_system or "all"
|
||||
items = _outbox_items(status=status, target_system=target_system, limit=limit)
|
||||
return HTMLResponse(render_outbox_table_partial(items, status=status, target_system=target_system))
|
||||
|
||||
|
||||
@router.get("/outbox", response_class=HTMLResponse)
|
||||
async def outbox_page(
|
||||
status: Optional[str] = None,
|
||||
target_system: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
):
|
||||
status = status or "all"
|
||||
target_system = target_system or "all"
|
||||
items = _outbox_items(status=status, target_system=target_system, limit=limit)
|
||||
|
||||
status_filters = "".join(
|
||||
f'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=value, target_system=target_system)}" hx-get="{_outbox_partial_url(status=value, target_system=target_system)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=value, target_system=target_system)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
|
||||
for value, label in [("all", "Todas"), ("pending", "Pendentes"), ("processing", "A processar"), ("sent", "Processadas"), ("failed", "Falhadas"), ("blocked", "Bloqueadas"), ("stale", "Stale"), ("dry_run", "Dry-run"), ("ignored", "Ignoradas"), ("cancelled", "Canceladas")]
|
||||
)
|
||||
system_filters = "".join(
|
||||
f'<a class="btn btn-sm {"btn-primary" if target_system == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=status, target_system=value)}" hx-get="{_outbox_partial_url(status=status, target_system=value)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=status, target_system=value)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
|
||||
for value, label in [("all", "Todos"), ("jasmin", "Jasmin"), ("packlink", "Packlink"), ("chatwoot", "Chatwoot"), ("mautic", "Mautic"), ("odoo", "Odoo")]
|
||||
)
|
||||
|
||||
body = f'''
|
||||
<section class="card cf-card cf-filter-card">
|
||||
<div class="card-body p-3 d-grid gap-3">
|
||||
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
|
||||
<div><strong class="me-2">Sistema:</strong><span class="d-inline-flex flex-wrap gap-2">{system_filters}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div><h2 class="cf-section-title">Outbox operacional</h2><div class="small text-secondary">Reprocessa ou ignora ações sem usar SQL no terminal. Filtros e ações atualizam por HTMX.</div></div>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/integrations">Integrações</a>
|
||||
</div>
|
||||
{render_outbox_table_partial(items, status=status, target_system=target_system)}
|
||||
</div>
|
||||
</section>
|
||||
'''
|
||||
return layout("Outbox", "Ações pendentes e histórico de integrações externas", body, "outbox")
|
||||
|
||||
|
||||
@router.get("/outbox/{outbox_id}", response_class=HTMLResponse)
|
||||
async def outbox_detail(outbox_id: str):
|
||||
item = get_outbox_item(outbox_id)
|
||||
|
||||
if not item:
|
||||
return layout("Outbox item", "Item não encontrado.", "<p>Não encontrado.</p>", "outbox")
|
||||
|
||||
payload = item.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except Exception:
|
||||
payload = {}
|
||||
opp_id = payload.get("opportunity_id") or ""
|
||||
origin_href = ("/opportunities/" + esc(opp_id)) if opp_id else "/outbox"
|
||||
origin_label = "Oportunidade" if opp_id else "—"
|
||||
human_error = outbox_operator_message(item)
|
||||
fix_href = origin_href if opp_id else "/outbox"
|
||||
body = f'''
|
||||
<p><a class="cf-row-link" href="/outbox">← Voltar à outbox</a></p>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Estado', esc(item.get('status')), None, 'atual', 'bi-info-circle')}
|
||||
{kpi_card('Sistema', esc(item.get('target_system')), None, esc(item.get('action_type')), 'bi-puzzle')}
|
||||
{kpi_card('Retries', esc(item.get('retry_count')), None, 'tentativas', 'bi-arrow-clockwise')}
|
||||
{kpi_card('Origem', origin_label, origin_href, esc(opp_id), 'bi-link-45deg')}
|
||||
</section>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Leitura operacional</h2>
|
||||
<div class="cf-outbox-human-error">
|
||||
<strong>{esc(human_error.get('title'))}</strong>
|
||||
<div class="small mt-1">Motivo provável: {esc(human_error.get('probable'))}</div>
|
||||
<a class="btn btn-sm btn-outline-primary mt-3" href="{esc(fix_href)}">{esc(human_error.get('fix_label'))}</a>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Payload</h2>
|
||||
<pre>{esc(pretty_json(payload))}</pre>
|
||||
<h2 class="cf-section-title mb-3 mt-4">Erro técnico</h2>
|
||||
<pre>{esc(item.get('last_error') or '')}</pre>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<form method="post" action="/outbox/{esc(item['id'])}/pending" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-outline-primary" type="submit">Reprocessar</button></form>
|
||||
<form method="post" action="/outbox/{esc(item['id'])}/ignored" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-outline-secondary" type="submit">Ignorar</button></form>
|
||||
<form method="post" action="/outbox/{esc(item['id'])}/failed" hx-confirm="Marcar este item como failed?"><button class="btn btn-outline-danger" type="submit">Marcar failed</button></form>
|
||||
</div>
|
||||
</div></section>
|
||||
'''
|
||||
return layout("Outbox item", f"Detalhe {outbox_id}", body, "outbox")
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/retry")
|
||||
async def outbox_retry(outbox_id: str, request: Request):
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
set_outbox_status(outbox_id=outbox_id, status="pending")
|
||||
if opportunity_id and is_htmx(request):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Item de outbox reposto para pending."))
|
||||
if opportunity_id:
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20outbox%20reposto%20para%20pending", status_code=303)
|
||||
return RedirectResponse("/outbox", status_code=303)
|
||||
|
||||
|
||||
def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_system: str = "all"):
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(render_outbox_table_partial(_outbox_items(status=status, target_system=target_system)))
|
||||
return RedirectResponse("/outbox", status_code=303)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/pending")
|
||||
async def outbox_pending(outbox_id: str, request: Request):
|
||||
set_outbox_status(outbox_id=outbox_id, status="pending")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_reprocess_requested", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "pending"})
|
||||
return _outbox_htmx_or_redirect(request)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/sent")
|
||||
async def outbox_sent(outbox_id: str, request: Request):
|
||||
set_outbox_status(outbox_id=outbox_id, status="sent")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_marked_sent", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "sent"})
|
||||
return _outbox_htmx_or_redirect(request)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/failed")
|
||||
async def outbox_failed(outbox_id: str, request: Request):
|
||||
set_outbox_status(outbox_id=outbox_id, status="failed", error="Marcado manualmente como failed.")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_marked_failed", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "failed"})
|
||||
return _outbox_htmx_or_redirect(request)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/ignored")
|
||||
async def outbox_ignored(outbox_id: str, request: Request):
|
||||
set_outbox_status(outbox_id=outbox_id, status="ignored", error="Ignorado manualmente pelo operador.")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_ignored", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "ignored"})
|
||||
return _outbox_htmx_or_redirect(request)
|
||||
301
app/admin_ui/pages/products.py
Normal file
301
app/admin_ui/pages/products.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Product catalog and opportunity item 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
@router.get("/produtos", response_class=HTMLResponse)
|
||||
async def products_page(
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = "all",
|
||||
active: Optional[str] = "true",
|
||||
):
|
||||
products = list_products(q=q, category=category, active=("true" if active == "missing_jasmin" else active), limit=500)
|
||||
if active == "missing_jasmin":
|
||||
products = [p for p in products if not p.get("jasmin_sales_item")]
|
||||
categories = list_product_categories()
|
||||
|
||||
category_options = '<option value="all">Todas</option>'
|
||||
for cat in categories:
|
||||
selected = "selected" if category == cat else ""
|
||||
category_options += f'<option value="{esc(cat)}" {selected}>{esc(cat)}</option>'
|
||||
|
||||
active_options = ""
|
||||
for value, label in [("true", "Ativos"), ("missing_jasmin", "Ativos sem Artigo Jasmin"), ("false", "Inativos"), ("all", "Todos")]:
|
||||
selected = "selected" if (active or "true") == value else ""
|
||||
active_options += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
||||
|
||||
rows = ""
|
||||
for product in products:
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td><code>{esc(product.get('sku') or '—')}</code></td>
|
||||
<td><code>{esc(product.get('jasmin_sales_item') or '—')}</code></td>
|
||||
<td><a class="cf-row-link" href="/products/{esc(product.get('id'))}">{esc(product.get('name') or 'Produto')}</a><div class="small text-secondary text-break">{esc(product.get('description') or '')}</div></td>
|
||||
<td>{esc(product.get('category') or '—')}</td>
|
||||
<td class="text-end fw-bold">{money_html(product.get('default_unit_price'))}</td>
|
||||
<td class="text-end">{esc(product.get('vat_rate') or '23')}%</td>
|
||||
<td>{product_status_badge(product.get('active'))}</td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/products/{esc(product.get('id'))}">Abrir</a></td>
|
||||
</tr>
|
||||
'''
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="8" class="text-center text-secondary py-5">Sem produtos encontrados.</td></tr>'
|
||||
|
||||
body = f'''
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Produtos listados', len(products), '/products', 'catálogo', 'bi-box-seam')}
|
||||
{kpi_card('Ativos', sum(1 for p in products if p.get('active')), '/products?active=true', 'para orçamento', 'bi-check2-circle', 'cf-kpi-tone-green')}
|
||||
{kpi_card('Categorias', len(categories), '/products?category=Carregadores', 'organização', 'bi-tags')}
|
||||
{kpi_card('Novo produto', '+', '/products/new', 'adicionar', 'bi-plus-circle')}
|
||||
</section>
|
||||
|
||||
<section class="card cf-card cf-filter-card">
|
||||
<form method="get" action="/products" class="row g-3 align-items-end">
|
||||
<div class="col-lg-5">
|
||||
<label class="form-label small fw-bold text-secondary">Procurar produto</label>
|
||||
<input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="SKU, nome, categoria...">
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label small fw-bold text-secondary">Categoria</label>
|
||||
<select class="form-select" name="category">{category_options}</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<label class="form-label small fw-bold text-secondary">Estado</label>
|
||||
<select class="form-select" name="active">{active_options}</select>
|
||||
</div>
|
||||
<div class="col-lg-2 d-flex gap-2">
|
||||
<button class="btn btn-primary flex-fill" type="submit">Filtrar</button>
|
||||
<a class="btn btn-outline-secondary" href="/products">Limpar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h2 class="cf-section-title">Catálogo de produtos</h2>
|
||||
<div class="small text-secondary">Produtos e serviços usados nas oportunidades, orçamentos e encomendas.</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2"><a class="btn btn-outline-primary btn-sm" href="/products/validate-jasmin">Validar artigos Jasmin</a><a class="btn btn-primary btn-sm" href="/products/new">+ Novo produto</a></div>
|
||||
</div>
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table">
|
||||
<thead><tr><th>SKU/Odoo</th><th>Jasmin</th><th>Produto</th><th>Categoria</th><th class="text-end">Preço</th><th class="text-end">IVA</th><th>Estado</th><th></th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
'''
|
||||
return layout("Produtos", "Catálogo simples sem variantes, usado para propostas e encomendas", body, "products")
|
||||
|
||||
|
||||
@router.get("/products/validate-jasmin", response_class=HTMLResponse)
|
||||
async def products_validate_jasmin_page():
|
||||
products = list_products(active="true", limit=500)
|
||||
rows = ""
|
||||
checked = 0
|
||||
ok_count = 0
|
||||
missing_count = 0
|
||||
invalid_count = 0
|
||||
client = None
|
||||
if settings.jasmin_enabled:
|
||||
try:
|
||||
from app.jasmin_client import JasminClient
|
||||
client = JasminClient()
|
||||
except Exception as exc:
|
||||
rows += f'<tr><td colspan="5" class="text-danger">Erro de configuração Jasmin: {esc(exc)}</td></tr>'
|
||||
for product in products:
|
||||
jasmin_item = str(product.get("jasmin_sales_item") or "").strip()
|
||||
status = "Sem Artigo Jasmin"
|
||||
detail = ""
|
||||
chip = "cf-chip-orange"
|
||||
if not jasmin_item:
|
||||
missing_count += 1
|
||||
elif not client:
|
||||
status = "Não testado"
|
||||
detail = "Jasmin desativado ou configuração inválida"
|
||||
chip = "cf-chip-gray"
|
||||
else:
|
||||
checked += 1
|
||||
try:
|
||||
info = await client.get_sales_item(jasmin_item)
|
||||
if isinstance(info, dict) and info.get("itemKey"):
|
||||
status = "OK"
|
||||
detail = info.get("description") or info.get("itemKey") or ""
|
||||
chip = "cf-chip-green"
|
||||
ok_count += 1
|
||||
else:
|
||||
status = "Resposta inesperada"
|
||||
detail = str(info)[:180]
|
||||
chip = "cf-chip-red"
|
||||
invalid_count += 1
|
||||
except Exception as exc:
|
||||
status = "Não existe / erro"
|
||||
detail = str(exc)[:220]
|
||||
chip = "cf-chip-red"
|
||||
invalid_count += 1
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td><code>{esc(product.get('sku') or '—')}</code></td>
|
||||
<td><code>{esc(jasmin_item or '—')}</code></td>
|
||||
<td><a class="cf-row-link" href="/products/{esc(product.get('id'))}">{esc(product.get('name') or 'Produto')}</a></td>
|
||||
<td><span class="cf-chip {chip}">{esc(status)}</span></td>
|
||||
<td class="small text-secondary text-break">{esc(detail)}</td>
|
||||
</tr>
|
||||
'''
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="5" class="text-center text-secondary py-5">Sem produtos ativos.</td></tr>'
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('OK', ok_count, None, 'artigos válidos', 'bi-check2-circle', 'cf-kpi-tone-green')}
|
||||
{kpi_card('Sem artigo', missing_count, None, 'precisam mapeamento', 'bi-box-seam', 'cf-kpi-tone-orange')}
|
||||
{kpi_card('Inválidos/erro', invalid_count, None, 'corrigir antes de orçamentar', 'bi-exclamation-triangle', 'cf-kpi-tone-red')}
|
||||
{kpi_card('Testados', checked, None, 'consultas Jasmin', 'bi-patch-check')}
|
||||
</section>
|
||||
<section class="card cf-card"><div class="card-body p-0">
|
||||
<div class="p-3 border-bottom"><h2 class="cf-section-title">Validação de artigos Jasmin</h2><div class="small text-secondary">Confirma se products.jasmin_sales_item existe no Jasmin.</div></div>
|
||||
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>SKU/Odoo</th><th>Artigo Jasmin</th><th>Produto</th><th>Estado</th><th>Detalhe</th></tr></thead><tbody>{rows}</tbody></table></div>
|
||||
</div></section>
|
||||
'''
|
||||
return layout("Validar artigos Jasmin", "Verificação de mapeamentos de produtos", body, "products")
|
||||
|
||||
|
||||
@router.get("/products/new", response_class=HTMLResponse)
|
||||
async def product_new_page():
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Novo produto</h2>
|
||||
{product_form_html({}, action="/products", submit_label="Criar produto")}
|
||||
</div></section>
|
||||
'''
|
||||
return layout("Novo produto", "Adicionar produto ou serviço ao catálogo", body, "products")
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
async def product_create(request: Request):
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
product_id = create_product(data)
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
except Exception as exc:
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
|
||||
<section class="alert alert-danger">Erro ao criar produto: {esc(exc)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(data, action="/products", submit_label="Criar produto")}</div></section>
|
||||
'''
|
||||
return layout("Novo produto", "Corrige os campos e tenta novamente", body, "products")
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_class=HTMLResponse)
|
||||
async def product_detail_page(product_id: str):
|
||||
product = get_product(product_id)
|
||||
if not product:
|
||||
return layout("Produto não encontrado", "Catálogo", '<section class="cf-empty">Produto não encontrado.</section>', "products")
|
||||
|
||||
toggle_label = "Desativar" if product.get("active") else "Ativar"
|
||||
toggle_active = "false" if product.get("active") else "true"
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
|
||||
<div class="cf-two-col">
|
||||
<section class="card cf-card"><div class="card-body p-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<span class="text-secondary small fw-bold text-uppercase">Produto</span>
|
||||
<h2 class="h3 fw-bold mb-1">{esc(product.get('name') or 'Produto')}</h2>
|
||||
<div class="small text-secondary">SKU/Odoo <code>{esc(product.get('sku') or '—')}</code> · Jasmin <code>{esc(product.get('jasmin_sales_item') or '—')}</code> · {esc(product.get('category') or 'Geral')}</div>
|
||||
</div>
|
||||
{product_status_badge(product.get('active'))}
|
||||
</div>
|
||||
{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}
|
||||
</div></section>
|
||||
<aside class="cf-side">
|
||||
<section class="card cf-card"><div class="card-body p-3">
|
||||
<h2 class="cf-section-title mb-3">Resumo</h2>
|
||||
<div class="cf-soft-box mb-2"><div class="small text-secondary fw-bold">Preço base</div><strong>{money_html(product.get('default_unit_price'))}</strong></div>
|
||||
<div class="cf-soft-box mb-2"><div class="small text-secondary fw-bold">IVA</div><strong>{esc(product.get('vat_rate') or '23')}%</strong></div>
|
||||
<div class="cf-soft-box"><div class="small text-secondary fw-bold">Atualizado</div><strong>{esc(product.get('updated_at') or '—')}</strong></div>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-3">
|
||||
<h2 class="cf-section-title mb-3">Ações</h2>
|
||||
<form method="post" action="/products/{esc(product_id)}/toggle" class="d-grid">
|
||||
<input type="hidden" name="active" value="{esc(toggle_active)}">
|
||||
<button class="btn btn-outline-secondary" type="submit">{esc(toggle_label)} produto</button>
|
||||
</form>
|
||||
</div></section>
|
||||
</aside>
|
||||
</div>
|
||||
'''
|
||||
return layout(str(product.get("name") or "Produto"), "Editar produto do catálogo", body, "products")
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/update")
|
||||
async def product_update(product_id: str, request: Request):
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
update_product(product_id, data)
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
except Exception as exc:
|
||||
product = get_product(product_id) or data
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products/{esc(product_id)}">← Voltar ao produto</a>
|
||||
<section class="alert alert-danger">Erro ao guardar produto: {esc(exc)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}</div></section>
|
||||
'''
|
||||
return layout("Editar produto", "Corrige os campos e tenta novamente", body, "products")
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/toggle")
|
||||
async def product_toggle(product_id: str, request: Request):
|
||||
data = dict(await request.form())
|
||||
set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/items/add")
|
||||
async def opportunity_item_add(opportunity_id: str, request: Request):
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
add_opportunity_item(
|
||||
opportunity_id,
|
||||
product_id=data.get("product_id") or None,
|
||||
product_name=data.get("product_name") or None,
|
||||
jasmin_sales_item=data.get("jasmin_sales_item") or None,
|
||||
quantity=data.get("quantity") or "1",
|
||||
unit_price=data.get("unit_price") or None,
|
||||
discount_amount=data.get("discount_amount") or "0",
|
||||
status=data.get("status") or "INTERESTED",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ClientFlow add opportunity item failed: {exc}", flush=True)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto adicionado à oportunidade."))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Produto%20adicionado%20%C3%A0%20oportunidade", status_code=303)
|
||||
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/items/{item_id}/delete")
|
||||
async def opportunity_item_delete(opportunity_id: str, item_id: str, request: Request):
|
||||
try:
|
||||
delete_opportunity_item(item_id)
|
||||
except Exception as exc:
|
||||
print(f"ClientFlow delete opportunity item failed: {exc}", flush=True)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto removido."))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
||||
|
||||
|
||||
83
app/admin_ui/pages/queues.py
Normal file
83
app/admin_ui/pages/queues.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Operational queue diagnostic 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/queues", response_class=HTMLResponse)
|
||||
@router.get("/filas", response_class=HTMLResponse)
|
||||
async def queues_page():
|
||||
metrics = get_admin_dashboard_metrics()
|
||||
queue_defs = [
|
||||
("vendas", "Comercial", "Orçamentos, propostas e oportunidades", "pending_vendas", "cf-chip-blue"),
|
||||
("financeiro", "Financeiro", "Pagamentos, faturas e comprovativos", "pending_financeiro", "cf-chip-green"),
|
||||
("operacoes", "Logística", "Envios, recolhas e entregas", "pending_operacoes", "cf-chip-orange"),
|
||||
("suporte", "Suporte", "Técnico, garantia e pós-venda", "pending_suporte", "cf-chip-purple"),
|
||||
("rever", "Rever", "Casos ambíguos ou baixa confiança", "pending_rever", "cf-chip-gray"),
|
||||
]
|
||||
|
||||
rows = ""
|
||||
chart_items = ""
|
||||
for route_key, label, description, metric_key, chip_class in queue_defs:
|
||||
count = int(metrics.get(metric_key) or 0)
|
||||
active_tasks = []
|
||||
try:
|
||||
active_tasks = list_tasks(status="pending", route=route_key, limit=1)
|
||||
except Exception:
|
||||
active_tasks = []
|
||||
priority = "Alta" if route_key in {"financeiro", "operacoes"} and count else ("Média" if count else "Baixa")
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td><span class="cf-chip {chip_class}">{esc(label)}</span></td>
|
||||
<td>{esc(description)}</td>
|
||||
<td><strong>{count}</strong></td>
|
||||
<td><span class="cf-chip {'cf-chip-red' if priority == 'Alta' else 'cf-chip-orange' if priority == 'Média' else 'cf-chip-gray'}">{esc(priority)}</span></td>
|
||||
<td>
|
||||
<a class="btn btn-outline-primary btn-sm" href="/tasks?status=pending&route={esc(route_key)}">Abrir</a>
|
||||
</td>
|
||||
</tr>
|
||||
'''
|
||||
chart_items += f'''
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between"><span>{esc(label)}</span><strong>{count}</strong></div>
|
||||
<div class="progress" style="height:.55rem"><div class="progress-bar" style="width:{min(100, count * 4)}%"></div></div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
body = f'''
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-8">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom">
|
||||
<h2 class="cf-section-title">Filas</h2>
|
||||
<div class="small text-secondary">Gestão de distribuição de tarefas por equipa.</div>
|
||||
</div>
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table">
|
||||
<thead><tr><th>Fila</th><th>Descrição</th><th>Tarefas</th><th>Prioridade</th><th>Ações</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Distribuição de tarefas</h2>
|
||||
{chart_items}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
return layout("Filas", "Gestão de filas e distribuição de tarefas", body, "queues")
|
||||
|
||||
|
||||
746
app/admin_ui/pages/reconciliation.py
Normal file
746
app/admin_ui/pages/reconciliation.py
Normal file
@@ -0,0 +1,746 @@
|
||||
"""Operational reconciliation and external intake UI.
|
||||
|
||||
This page is a staging area for information found outside ClientFlow. It does
|
||||
not replace Operations; it prepares loose Jasmin/Odoo/payment/manual evidence so
|
||||
an operator can link, create or ignore it deliberately.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.admin_ui.components import esc, fmt_dt, kpi_card, money, status_chip
|
||||
from app.admin_ui.layout import layout
|
||||
from app.fiscal_enrichment_service import enrich_open_opportunities, fiscal_enrichment_summary
|
||||
from app.external_reconciliation_sync import (
|
||||
sync_all_external_reconciliation_candidates,
|
||||
sync_jasmin_reconciliation_candidates,
|
||||
sync_odoo_reconciliation_candidates,
|
||||
sync_packlink_reconciliation_candidates,
|
||||
)
|
||||
from app.reconciliation_decision_service import (
|
||||
classify_reconciliation_item,
|
||||
classify_reconciliation_process,
|
||||
reconciliation_decision_summary,
|
||||
sort_items_for_operator,
|
||||
)
|
||||
from app.reconciliation_service import (
|
||||
cleanup_reconciliation_outside_window,
|
||||
create_external_request,
|
||||
create_opportunity_from_reconciliation,
|
||||
create_payment_proof,
|
||||
link_reconciliation_to_opportunity,
|
||||
list_reconciliation_items,
|
||||
list_reconciliation_process_candidates,
|
||||
create_opportunity_from_reconciliation_process,
|
||||
link_reconciliation_process_to_opportunity,
|
||||
reconciliation_summary,
|
||||
recent_window_start,
|
||||
reset_generated_reconciliation_items,
|
||||
set_reconciliation_status,
|
||||
sync_local_documents_without_opportunity,
|
||||
upsert_reconciliation_item,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
TYPE_LABELS = {
|
||||
"jasmin_quotation": "Orçamento Jasmin",
|
||||
"jasmin_proforma": "Pró-forma Jasmin",
|
||||
"jasmin_invoice": "Fatura Jasmin",
|
||||
"odoo_sale_order": "Venda Odoo",
|
||||
"payment_proof": "Comprovativo",
|
||||
"packlink_shipment": "Envio Packlink",
|
||||
"manual_request": "Pedido externo",
|
||||
"external_record": "Registo externo",
|
||||
}
|
||||
|
||||
|
||||
def _type_label(value: str | None) -> str:
|
||||
return TYPE_LABELS.get(str(value or ""), str(value or "—"))
|
||||
|
||||
|
||||
def _safe_days(value: object, default: int = 3) -> int:
|
||||
try:
|
||||
return min(max(int(value or default), 1), 90)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _filter_url(status: str = "open", external_type: str = "all", days: int = 3) -> str:
|
||||
return f"/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={int(days or 3)}"
|
||||
|
||||
|
||||
|
||||
def _operation_label(value: str | None) -> str:
|
||||
raw = str(value or "")
|
||||
if not raw:
|
||||
return ""
|
||||
labels = {
|
||||
"odoo_sale_order": "Venda Odoo",
|
||||
"jasmin_quotation": "Orçamento Jasmin",
|
||||
"jasmin_proforma": "Pró-forma Jasmin",
|
||||
"jasmin_invoice": "Fatura Jasmin",
|
||||
"document": "Documento",
|
||||
}
|
||||
if ":" in raw:
|
||||
key, ref = raw.split(":", 1)
|
||||
return f"{labels.get(key, key)} {ref}"
|
||||
return raw
|
||||
|
||||
def _render_item_rows(items: list[dict]) -> str:
|
||||
rows = ""
|
||||
for item in items:
|
||||
item_id = str(item.get("id") or "")
|
||||
opportunity_link = "—"
|
||||
if item.get("opportunity_id"):
|
||||
opportunity_label = item.get("opportunity_title") or "Ver oportunidade"
|
||||
opportunity_link = f'<a href="/opportunities/{esc(item.get("opportunity_id"))}">{esc(opportunity_label)}</a>'
|
||||
|
||||
amount = "—"
|
||||
if item.get("amount") is not None:
|
||||
amount = money(item.get("amount"), item.get("currency") or "EUR")
|
||||
|
||||
actions = ""
|
||||
if str(item.get("status") or "") in {"open", "needs_review"}:
|
||||
suggestions = item.get("operation_suggestions") or []
|
||||
suggestion_html = ""
|
||||
if suggestions:
|
||||
suggestion_cards = ""
|
||||
for suggestion in suggestions[:2]:
|
||||
suggested_opp_id = str(suggestion.get("opportunity_id") or "")
|
||||
suggested_title = suggestion.get("opportunity_title") or "Oportunidade aberta"
|
||||
suggested_customer = suggestion.get("customer_name") or ""
|
||||
suggested_action = suggestion.get("action") or suggestion.get("action_code") or "operação aberta"
|
||||
suggested_reason = suggestion.get("reason") or "possível correspondência"
|
||||
suggestion_cards += f"""
|
||||
<form method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à operação/oportunidade sugerida?" class="cf-reconcile-suggestion">
|
||||
<input type="hidden" name="opportunity_id" value="{esc(suggested_opp_id)}">
|
||||
<div class="small text-secondary">Sugestão encontrada em Operations</div>
|
||||
<div class="fw-semibold text-break">{esc(suggested_title)}</div>
|
||||
<div class="small text-secondary text-break">{esc(suggested_customer)}</div>
|
||||
<div class="small text-secondary text-break">{esc(suggested_action)} · {esc(suggested_reason)}</div>
|
||||
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar a esta operação</button>
|
||||
</form>
|
||||
"""
|
||||
suggestion_html = f'<div class="d-grid gap-2 mb-2">{suggestion_cards}</div>'
|
||||
|
||||
create_button_class = "btn-outline-primary" if suggestions else "btn-primary"
|
||||
search_query = item.get("customer_tax_id") or item.get("customer_email") or item.get("customer_name") or item.get("document_number") or ""
|
||||
search_href = f"/opportunities?q={esc(str(search_query))}" if search_query else "/opportunities"
|
||||
manual_link = f"""
|
||||
<details class="cf-reconcile-manual-link small text-secondary mt-1">
|
||||
<summary>Escolher outra oportunidade</summary>
|
||||
<form class="d-flex gap-1 mt-2" method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à oportunidade indicada?">
|
||||
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:170px">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
|
||||
</form>
|
||||
</details>
|
||||
"""
|
||||
actions = f"""
|
||||
<div class="d-grid gap-2 justify-content-end">
|
||||
{suggestion_html}
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{search_href}">Pesquisar oportunidade</a>
|
||||
<form method="post" action="/reconciliation/{esc(item_id)}/create-opportunity" hx-confirm="Criar nova oportunidade a partir deste item? Use isto apenas se não houver operação aberta correspondente.">
|
||||
<button class="btn btn-sm {create_button_class}" type="submit">Criar oportunidade</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/{esc(item_id)}/ignore" hx-confirm="Ignorar este item de reconciliação?">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/{esc(item_id)}/needs-review" hx-confirm="Enviar este item para revisão manual?">
|
||||
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/{esc(item_id)}/historical" hx-confirm="Marcar este item como histórico sem ação operacional?">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
|
||||
</form>
|
||||
</div>
|
||||
{manual_link}
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
actions = '<span class="text-secondary small">Sem ações pendentes</span>'
|
||||
|
||||
decision = classify_reconciliation_item(item)
|
||||
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">{esc(item.get('title'))}</div>
|
||||
<div class="small text-secondary text-break">{esc(item.get('description') or '')}</div>
|
||||
<div class="small text-secondary mt-1">{esc(item.get('source_system'))} · {esc(item.get('external_id') or 'sem external_id')}</div>
|
||||
</td>
|
||||
<td><span class="cf-chip cf-chip-blue">{esc(_type_label(item.get('external_type')))}</span><div class="small mt-1">{esc(item.get('document_number') or '')}</div></td>
|
||||
<td>{status_chip(item.get('status'))}<div class="small text-secondary mt-1">{esc(item.get('priority') or 'normal')}</div><div class="mt-1">{decision_chip}</div><div class="small text-secondary mt-1">{esc(decision.get('primary_decision') or '')}</div></td>
|
||||
<td><div>{esc(item.get('customer_name') or item.get('linked_customer_name') or '—')}</div><div class="small text-secondary">{esc(item.get('customer_email') or '')}</div>{f'<div class="small text-secondary">NIF {esc(item.get("customer_tax_id"))}</div>' if item.get('customer_tax_id') else ''}</td>
|
||||
<td>{amount}<div class="small text-secondary">{esc(fmt_dt(item.get('document_date')))}</div></td>
|
||||
<td>{opportunity_link}</td>
|
||||
<td class="text-end">{actions}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Sem itens de reconciliação para este filtro.</td></tr>'
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
def _render_process_candidates(candidates: list[dict], *, days: int = 3) -> str:
|
||||
"""Render grouped timeline reconstruction proposals.
|
||||
|
||||
These cards are intentionally more useful than isolated rows: they show the
|
||||
probable sequence found across Jasmin/Odoo/Packlink/payment evidence and let
|
||||
the operator create or link one process deliberately.
|
||||
"""
|
||||
if not candidates:
|
||||
return """
|
||||
<section class="card cf-card mb-4">
|
||||
<div class="card-body p-4 text-secondary">
|
||||
<h2 class="cf-section-title mb-2">Processos candidatos</h2>
|
||||
<div>Sem processos reconstruíveis na janela ativa. Itens isolados continuam disponíveis abaixo.</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
cards = ""
|
||||
for candidate in candidates[:8]:
|
||||
item_ids = ",".join(str(x) for x in candidate.get("item_ids") or [])
|
||||
tax_line = f'<div class="small text-secondary">NIF {esc(candidate.get("customer_tax_id"))}</div>' if candidate.get("customer_tax_id") else ""
|
||||
email_line = f'<div class="small text-secondary text-break">{esc(candidate.get("customer_email"))}</div>' if candidate.get("customer_email") else ""
|
||||
amount = ""
|
||||
if candidate.get("amount") is not None:
|
||||
amount = f'<span class="cf-chip cf-chip-gray">{money(candidate.get("amount"), candidate.get("currency") or "EUR")}</span>'
|
||||
identity_line = f'<div class="small text-secondary">Cliente fiscal: {esc(candidate.get("identity_reason") or candidate.get("match_key") or "—")}</div>'
|
||||
operation_label = _operation_label(candidate.get("operation_key"))
|
||||
operation_line = f'<div class="small text-secondary">Compra/processo: {esc(operation_label)}</div>' if operation_label else ""
|
||||
review_status = str(candidate.get("review_status") or "needs_review")
|
||||
review_label = {"ready": "pronto", "needs_review": "rever", "conflict": "conflito"}.get(review_status, review_status)
|
||||
review_class = "green" if review_status == "ready" else "orange" if review_status == "needs_review" else "red"
|
||||
review_chip = f'<span class="cf-chip cf-chip-{review_class}">{esc(review_label)}</span>'
|
||||
decision = classify_reconciliation_process(candidate)
|
||||
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
|
||||
reasons = "".join(f'<li>{esc(reason)}</li>' for reason in (candidate.get("reasons") or [])[:5])
|
||||
risks = "".join(f'<li>{esc(risk)}</li>' for risk in (candidate.get("risks") or [])[:5])
|
||||
explanation_html = (
|
||||
'<div class="cf-reconcile-explain">'
|
||||
f'<div><strong>Motivos</strong><ul>{reasons or "<li>candidato reconstruído por identidade fiscal/processo</li>"}</ul></div>'
|
||||
f'<div><strong>Riscos</strong><ul>{risks or "<li>sem riscos relevantes detetados</li>"}</ul></div>'
|
||||
'</div>'
|
||||
)
|
||||
timeline = ""
|
||||
for step in candidate.get("timeline") or []:
|
||||
timeline += f"""
|
||||
<div class="cf-reconcile-timeline-step">
|
||||
<div class="small text-secondary">{esc(fmt_dt(step.get('document_date')))}</div>
|
||||
<div><strong>{esc(step.get('label'))}</strong></div>
|
||||
<div class="small text-secondary text-break">{esc(step.get('document_number') or step.get('title') or '')}</div>
|
||||
</div>
|
||||
"""
|
||||
suggestion_html = ""
|
||||
suggestions = candidate.get("suggestions") or []
|
||||
if suggestions:
|
||||
suggestion = suggestions[0]
|
||||
suggestion_html = f"""
|
||||
<form method="post" action="/reconciliation/processes/link" hx-confirm="Ligar todos os itens deste processo à oportunidade sugerida?" class="cf-reconcile-suggestion">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<input type="hidden" name="opportunity_id" value="{esc(suggestion.get('opportunity_id') or '')}">
|
||||
<div class="small text-secondary">Sugestão de oportunidade existente</div>
|
||||
<div class="fw-semibold text-break">{esc(suggestion.get('opportunity_title') or 'Oportunidade aberta')}</div>
|
||||
<div class="small text-secondary text-break">{esc(suggestion.get('customer_name') or '')} · {esc(suggestion.get('reason') or 'possível correspondência')}</div>
|
||||
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar processo à sugestão</button>
|
||||
</form>
|
||||
"""
|
||||
cards += f"""
|
||||
<article class="cf-reconcile-process-card">
|
||||
<div class="d-flex justify-content-between gap-3 align-items-start flex-wrap">
|
||||
<div>
|
||||
<h3 class="h6 mb-1">{esc(candidate.get('customer_name') or 'Processo externo')}</h3>
|
||||
{email_line}{tax_line}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-end">
|
||||
<span class="cf-chip cf-chip-blue">{len(candidate.get('items') or [])} evidência(s)</span>
|
||||
<span class="cf-chip cf-chip-orange">confiança {esc(candidate.get('confidence') or 'média')}</span>
|
||||
{review_chip}
|
||||
{decision_chip}
|
||||
{amount}
|
||||
</div>
|
||||
</div>
|
||||
{identity_line}{operation_line}
|
||||
<div class="cf-reconcile-decision-note"><strong>{esc(decision.get('primary_decision') or 'Decisão pendente')}</strong><span>{esc(decision.get('description') or '')}</span></div>
|
||||
{explanation_html}
|
||||
<div class="cf-reconcile-process-state">
|
||||
<div><span>Estado sugerido</span><strong>{esc(candidate.get('suggested_stage') or 'REVIEW')}</strong></div>
|
||||
<div><span>Próxima ação</span><strong>{esc(candidate.get('suggested_action') or 'REVIEW_MANUALLY')}</strong></div>
|
||||
</div>
|
||||
<div class="cf-reconcile-timeline">{timeline}</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start">
|
||||
{suggestion_html}
|
||||
<form method="post" action="/reconciliation/processes/create-opportunity" hx-confirm="Criar uma oportunidade reconstruída com esta timeline?">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit">Criar oportunidade reconstruída</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/processes/needs-review" hx-confirm="Enviar este processo para revisão manual?">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/processes/historical" hx-confirm="Marcar este processo como histórico sem ação operacional?">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
|
||||
</form>
|
||||
<form method="post" action="/reconciliation/processes/ignore" hx-confirm="Ignorar este processo? Use apenas quando pertence a histórico externo ou não deve criar ação operacional.">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
|
||||
</form>
|
||||
<details class="small text-secondary cf-reconcile-manual-link">
|
||||
<summary>Ligar a outra oportunidade</summary>
|
||||
<form method="post" action="/reconciliation/processes/link" class="d-flex gap-1 mt-2" hx-confirm="Ligar todos os itens deste processo à oportunidade indicada?">
|
||||
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
|
||||
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:180px">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
"""
|
||||
return f"""
|
||||
<section class="card cf-card mb-4">
|
||||
<div class="card-body p-3 border-bottom">
|
||||
<h2 class="cf-section-title mb-1">Processos candidatos</h2>
|
||||
<div class="small text-secondary">O sistema agrupa primeiro por cliente fiscal e depois separa por compra/processo antes de criar ou ligar a oportunidade.</div>
|
||||
</div>
|
||||
<div class="card-body p-3 d-grid gap-3">{cards}</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
def render_reconciliation_decision_board(items: list[dict], candidates: list[dict], *, status: str, external_type: str, days: int) -> str:
|
||||
summary = reconciliation_decision_summary(items, candidates)
|
||||
cards = [
|
||||
("actionable", "Ação recomendada", "Ligar/criar com evidência suficiente", "open"),
|
||||
("review", "Requer revisão", "Conflitos ou baixa confiança", "needs_review"),
|
||||
("historical", "Histórico", "Sem ação operacional", "historical"),
|
||||
("ignored", "Ignorados", "Fora da fila", "ignored"),
|
||||
("resolved", "Resolvidos", "Ligados ou fechados", "linked"),
|
||||
]
|
||||
html = ""
|
||||
for key, label, description, target_status in cards:
|
||||
active = " active" if ((key == "actionable" and status == "open") or status == target_status) else ""
|
||||
href = _filter_url(target_status, external_type, days)
|
||||
html += f'''
|
||||
<a class="cf-decision-card{active}" href="{href}">
|
||||
<span>{esc(label)}</span>
|
||||
<strong>{int(summary.get(key, 0))}</strong>
|
||||
<small>{esc(description)}</small>
|
||||
</a>
|
||||
'''
|
||||
return f'''
|
||||
<section class="cf-reconcile-decision-grid mb-3" aria-label="Resumo decisional de reconciliação">
|
||||
{html}
|
||||
</section>
|
||||
'''
|
||||
|
||||
|
||||
def render_reconciliation_table(items: list[dict]) -> str:
|
||||
return f"""
|
||||
<div class="cf-table-wrap border-0 rounded-0">
|
||||
<table class="table cf-table align-middle">
|
||||
<thead><tr><th>Item</th><th>Tipo</th><th>Estado</th><th>Cliente</th><th>Valor/data</th><th>Oportunidade</th><th class="text-end">Ações</th></tr></thead>
|
||||
<tbody>{_render_item_rows(items)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/reconciliation", response_class=HTMLResponse)
|
||||
@router.get("/reconciliacao", response_class=HTMLResponse)
|
||||
async def reconciliation_page(status: Optional[str] = "open", external_type: Optional[str] = "all", notice: Optional[str] = None, days: Optional[int] = 3):
|
||||
status = status or "open"
|
||||
external_type = external_type or "all"
|
||||
item_type = None if external_type == "all" else external_type
|
||||
recent_days = min(max(int(days or 3), 1), 90)
|
||||
items = sort_items_for_operator(list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days))
|
||||
process_candidates = list_reconciliation_process_candidates(status="open", days=recent_days, limit=8)
|
||||
summary = reconciliation_summary(days=recent_days)
|
||||
enrichment_summary = fiscal_enrichment_summary()
|
||||
|
||||
notice_html = f'<div class="alert alert-info border-0">{esc(notice)}</div>' if notice else ""
|
||||
status_filters = "".join(
|
||||
f'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_filter_url(value, external_type, recent_days)}">{esc(label)}</a>'
|
||||
for value, label in [("open", "Abertos"), ("needs_review", "Revisão"), ("conflict", "Conflitos"), ("historical", "Histórico"), ("linked", "Ligados"), ("resolved", "Resolvidos"), ("ignored", "Ignorados"), ("all", "Todos")]
|
||||
)
|
||||
type_filters = "".join(
|
||||
f'<a class="btn btn-sm {"btn-primary" if external_type == value else "btn-outline-secondary"}" href="{_filter_url(status, value, recent_days)}">{esc(label)}</a>'
|
||||
for value, label in [("all", "Todos"), ("jasmin_quotation", "Orçamentos"), ("jasmin_invoice", "Faturas"), ("payment_proof", "Comprovativos"), ("odoo_sale_order", "Odoo"), ("packlink_shipment", "Packlink"), ("manual_request", "Externos")]
|
||||
)
|
||||
|
||||
body = f"""
|
||||
{notice_html}
|
||||
<section class="alert alert-primary border-0 shadow-sm">
|
||||
<strong>Reconciliação = informação solta para organizar.</strong>
|
||||
O sistema cria candidatos por cliente fiscal e por compra/processo; o operador decide ligar, criar oportunidade ou ignorar. Janela ativa: últimos {recent_days} dias operacionais, desde {esc(recent_window_start(recent_days))}. Comprovativo recebido não confirma pagamento.
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Itens abertos', summary.get('open', 0), '/reconciliation', 'pendências para ligar/criar/ignorar', 'bi-diagram-3')}
|
||||
{kpi_card('Conflitos', summary.get('conflict', 0), '/reconciliation?status=conflict', 'cliente/processo com risco', 'bi-exclamation-triangle')}
|
||||
{kpi_card('Histórico', summary.get('historical', 0), '/reconciliation?status=historical', 'fora da operação diária', 'bi-archive')}
|
||||
{kpi_card('Resolvidos', summary.get('resolved', 0), '/reconciliation?status=linked', 'ligados ou fechados', 'bi-check2-circle')}
|
||||
{kpi_card('Sugestões fiscais', enrichment_summary.get('pending_suggestions', 0), '/opportunities?scope=blocked', 'pendentes antes da reconciliação', 'bi-person-vcard')}
|
||||
</section>
|
||||
|
||||
{render_reconciliation_decision_board(items, process_candidates, status=status, external_type=external_type, days=recent_days)}
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-3 d-grid gap-3">
|
||||
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
|
||||
<div><strong class="me-2">Tipo:</strong><span class="d-inline-flex flex-wrap gap-2">{type_filters}</span></div>
|
||||
<div><strong class="me-2">Janela:</strong><span class="d-inline-flex flex-wrap gap-2">
|
||||
{''.join(f'<a class="btn btn-sm {"btn-primary" if recent_days == value else "btn-outline-secondary"}" href="/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={value}">{label}</a>' for value, label in [(1, 'Hoje'), (3, '3 dias'), (7, '7 dias'), (30, '30 dias')])}
|
||||
</span></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/sync-local-documents" hx-confirm="Procurar documentos locais sem oportunidade?">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit">Procurar documentos locais</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/sync-jasmin" hx-confirm="Consultar API Jasmin e criar candidatos de reconciliação?">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Jasmin</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/sync-odoo" hx-confirm="Consultar API Odoo e criar candidatos de reconciliação?">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Odoo</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/sync-packlink" hx-confirm="Consultar API Packlink e criar candidatos de reconciliação?">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Packlink</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/enrich-fiscal" hx-confirm="Enriquecer oportunidades abertas sem cliente fiscal antes da reconciliação? Auto-associa apenas matches muito fortes.">
|
||||
<button class="btn btn-sm btn-outline-success" type="submit">Enriquecer oportunidades</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/sync-external" hx-confirm="Consultar APIs externas ativas na janela ativa? A sequência é: enriquecimento fiscal → clientes fiscais → documentos Jasmin → vendas Odoo → envios Packlink. Não cria oportunidades automaticamente.">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-primary" type="submit">Sincronizar APIs externas</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/rebuild" hx-confirm="Reconstruir a reconciliação da janela ativa? Vai apagar apenas candidatos gerados por Jasmin/Odoo/Packlink sem oportunidade ligada, criar backup e correr a sequência enriquecimento fiscal → clientes fiscais → Jasmin → Odoo → faturas/envios. Não apaga clientes, oportunidades nem documentos reais.">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-danger" type="submit">Apagar e correr novamente</button>
|
||||
</form>
|
||||
<form class="d-inline-block" method="post" action="/reconciliation/cleanup-window" hx-confirm="Limpar da fila aberta os candidatos fora da janela ativa? Não apaga documentos externos.">
|
||||
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-danger" type="submit">Limpar fora dos 3 dias/janela</button>
|
||||
</form>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
{_render_process_candidates(process_candidates, days=recent_days)}
|
||||
|
||||
<section class="card cf-card mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom"><h2 class="cf-section-title mb-1">Itens soltos</h2><div class="small text-secondary">Itens individuais que ainda precisam de decisão ou que não formaram um processo candidato.</div></div>
|
||||
{render_reconciliation_table(items)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6">
|
||||
<section class="card cf-card h-100"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Registar pedido externo</h2>
|
||||
<form method="post" action="/reconciliation/manual-request" class="d-grid gap-3">
|
||||
<div class="row g-2"><div class="col-md-4"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_channel"><option>WhatsApp</option><option>Telefone</option><option>Email direto</option><option>Presencial</option><option>Outro</option></select></div><div class="col-md-8"><label class="form-label small fw-bold">Nome/contacto</label><input class="form-control" name="customer_name" required></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone"></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Emitir pró-forma</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
|
||||
<div><label class="form-label small fw-bold">Mensagem/pedido</label><textarea class="form-control" rows="3" name="request_text" placeholder="Colar texto do WhatsApp, telefone ou email direto..."></textarea></div>
|
||||
<div><button class="btn btn-primary" type="submit">Guardar e criar oportunidade</button></div>
|
||||
</form>
|
||||
</div></section>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-6">
|
||||
<section class="card cf-card h-100"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Adicionar comprovativo</h2>
|
||||
<form method="post" action="/reconciliation/payment-proof" class="d-grid gap-3">
|
||||
<div class="alert alert-warning border-0 py-2 mb-0"><strong>Regra:</strong> comprovativo recebido abre validação financeira; não confirma pagamento.</div>
|
||||
<div class="row g-2"><div class="col-md-7"><label class="form-label small fw-bold">ID oportunidade, se conhecido</label><input class="form-control" name="opportunity_id" placeholder="opcional"></div><div class="col-md-5"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_system"><option>WhatsApp</option><option>Chatwoot</option><option>Email</option><option>Manual</option></select></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Valor indicado</label><input class="form-control" name="amount" placeholder="190,00"></div><div class="col-md-6"><label class="form-label small fw-bold">Ficheiro/ref.</label><input class="form-control" name="filename" placeholder="comprovativo.jpg"></div></div>
|
||||
<div><label class="form-label small fw-bold">Nota</label><textarea class="form-control" rows="3" name="note" placeholder="Referência, banco, MB Way, observações..."></textarea></div>
|
||||
<div><button class="btn btn-primary" type="submit">Guardar comprovativo</button></div>
|
||||
</form>
|
||||
</div></section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card cf-card mt-3"><div class="card-body p-4">
|
||||
<h2 class="cf-section-title mb-3">Criar candidato manual de reconciliação</h2>
|
||||
<form method="post" action="/reconciliation/item" class="row g-2 align-items-end">
|
||||
<div class="col-md-2"><label class="form-label small fw-bold">Sistema</label><select class="form-select" name="source_system"><option>jasmin</option><option>odoo</option><option>manual</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label small fw-bold">Tipo</label><select class="form-select" name="external_type"><option value="jasmin_quotation">Orçamento</option><option value="jasmin_invoice">Fatura</option><option value="odoo_sale_order">Venda Odoo</option><option value="external_record">Outro</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label small fw-bold">Nº documento</label><input class="form-control" name="document_number"></div>
|
||||
<div class="col-md-2"><label class="form-label small fw-bold">Cliente</label><input class="form-control" name="customer_name"></div>
|
||||
<div class="col-md-2"><label class="form-label small fw-bold">Valor</label><input class="form-control" name="amount"></div>
|
||||
<div class="col-md-2"><button class="btn btn-outline-primary w-100" type="submit">Criar candidato</button></div>
|
||||
</form>
|
||||
</div></section>
|
||||
"""
|
||||
return layout("Reconciliação", "Organizar documentos, vendas e comprovativos fora do ClientFlow", body, "reconciliation")
|
||||
|
||||
|
||||
@router.post("/reconciliation/sync-local-documents")
|
||||
async def reconciliation_sync_local_documents():
|
||||
result = sync_local_documents_without_opportunity(limit=200)
|
||||
notice = f"Documentos analisados: {result.get('seen', 0)} · candidatos criados/atualizados: {result.get('created_or_updated', 0)}"
|
||||
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/sync-jasmin")
|
||||
async def reconciliation_sync_jasmin(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = await sync_jasmin_reconciliation_candidates(limit=50, days=days)
|
||||
notice = f"Jasmin últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
if result.get("errors"):
|
||||
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
|
||||
if result.get("skipped"):
|
||||
notice += " · " + str(result.get("skipped"))
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/sync-odoo")
|
||||
async def reconciliation_sync_odoo(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = sync_odoo_reconciliation_candidates(limit=50, days=days)
|
||||
notice = f"Odoo últimos {result.get('days', days)} dias: analisadas {result.get('seen', 0)} vendas · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
if result.get("skipped"):
|
||||
notice += " · " + str(result.get("skipped"))
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/sync-packlink")
|
||||
async def reconciliation_sync_packlink(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = await sync_packlink_reconciliation_candidates(limit=50, days=days)
|
||||
notice = f"Packlink últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} envios · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
if result.get("errors"):
|
||||
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
|
||||
if result.get("skipped"):
|
||||
notice += " · " + str(result.get("skipped"))
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/enrich-fiscal")
|
||||
async def reconciliation_enrich_fiscal(request: Request):
|
||||
result = enrich_open_opportunities(limit=200, apply_safe=True, mode="operator_ui")
|
||||
notice = (
|
||||
f"Enriquecimento fiscal: analisadas {result.get('seen', 0)} oportunidades"
|
||||
f" · sugestões {result.get('suggested', 0)}"
|
||||
f" · auto-associadas {result.get('auto_applied', 0)}"
|
||||
f" · ignoradas {result.get('skipped', 0)}"
|
||||
)
|
||||
if result.get("errors"):
|
||||
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
|
||||
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/sync-external")
|
||||
async def reconciliation_sync_external(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = await sync_all_external_reconciliation_candidates(limit=50, days=days)
|
||||
notice = (
|
||||
f"APIs externas últimos {days} dias: enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
|
||||
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
|
||||
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
|
||||
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
|
||||
f" · documentos/vendas analisados {result.get('seen', 0)}"
|
||||
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
)
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/rebuild")
|
||||
async def reconciliation_rebuild(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
reset = reset_generated_reconciliation_items(days=days, limit=2000, actor="operator_ui", apply=True)
|
||||
result = await sync_all_external_reconciliation_candidates(limit=200, days=days)
|
||||
notice = (
|
||||
f"Reconciliação reconstruída para {days} dias: apagados {reset.get('deleted', 0)} candidatos gerados"
|
||||
f" · backup {reset.get('backup_table') or 'sem alterações'}"
|
||||
f" · enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
|
||||
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
|
||||
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
|
||||
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
|
||||
f" · documentos/vendas analisados {result.get('seen', 0)}"
|
||||
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
)
|
||||
errors = []
|
||||
for source_result in result.get("results", []) or []:
|
||||
errors.extend(str(err) for err in source_result.get("errors", []) or [])
|
||||
if errors:
|
||||
notice += " · erros: " + "; ".join(errors[:2])
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/cleanup-window")
|
||||
async def reconciliation_cleanup_window(request: Request):
|
||||
form = await request.form()
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = cleanup_reconciliation_outside_window(days=days, limit=2000, actor="operator_ui")
|
||||
notice = f"Limpeza aplicada: {result.get('ignored', 0)} itens fora dos últimos {result.get('days', days)} dias foram marcados como ignorados."
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/manual-request")
|
||||
async def reconciliation_manual_request(request: Request):
|
||||
form = await request.form()
|
||||
result = create_external_request(
|
||||
source_channel=str(form.get("source_channel") or "manual"),
|
||||
customer_name=str(form.get("customer_name") or ""),
|
||||
customer_email=str(form.get("customer_email") or ""),
|
||||
customer_phone=str(form.get("customer_phone") or ""),
|
||||
product_interest=str(form.get("product_interest") or ""),
|
||||
request_text=str(form.get("request_text") or ""),
|
||||
action_code=str(form.get("action_code") or "SEND_QUOTE"),
|
||||
)
|
||||
opportunity_id = result.get("opportunity_id")
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20externo%20registado", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/payment-proof")
|
||||
async def reconciliation_payment_proof(request: Request):
|
||||
form = await request.form()
|
||||
proof = create_payment_proof(
|
||||
opportunity_id=str(form.get("opportunity_id") or "").strip() or None,
|
||||
source_system=str(form.get("source_system") or "manual"),
|
||||
source_ref=str(form.get("source_ref") or ""),
|
||||
filename=str(form.get("filename") or ""),
|
||||
amount=str(form.get("amount") or ""),
|
||||
note=str(form.get("note") or ""),
|
||||
)
|
||||
if proof.get("task_id") and proof.get("opportunity_id"):
|
||||
return RedirectResponse(f"/opportunities/{proof.get('opportunity_id')}?notice=Comprovativo%20registado%20para%20validação", status_code=303)
|
||||
return RedirectResponse("/reconciliation?external_type=payment_proof¬ice=Comprovativo%20por%20associar%20registado", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/item")
|
||||
async def reconciliation_create_item(request: Request):
|
||||
form = await request.form()
|
||||
source_system = str(form.get("source_system") or "manual")
|
||||
external_type = str(form.get("external_type") or "external_record")
|
||||
document_number = str(form.get("document_number") or "").strip()
|
||||
customer_name = str(form.get("customer_name") or "").strip()
|
||||
title = f"{_type_label(external_type)} sem ligação"
|
||||
if document_number:
|
||||
title += f" · {document_number}"
|
||||
upsert_reconciliation_item(
|
||||
source_system=source_system,
|
||||
external_type=external_type,
|
||||
external_id=document_number or None,
|
||||
title=title,
|
||||
description="Candidato criado manualmente para ligação/criação de oportunidade.",
|
||||
customer_name=customer_name,
|
||||
document_number=document_number,
|
||||
amount=str(form.get("amount") or ""),
|
||||
suggested_action="CONFIRM_PAYMENT" if external_type == "jasmin_invoice" else "SEND_PROFORMA",
|
||||
)
|
||||
return RedirectResponse("/reconciliation?notice=Candidato%20criado", status_code=303)
|
||||
|
||||
|
||||
|
||||
@router.post("/reconciliation/processes/create-opportunity")
|
||||
async def reconciliation_create_process_opportunity(request: Request):
|
||||
form = await request.form()
|
||||
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
|
||||
opportunity_id = create_opportunity_from_reconciliation_process(item_ids)
|
||||
if opportunity_id:
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20reconstruída%20a%20partir%20da%20reconciliação", status_code=303)
|
||||
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20reconstruir%20oportunidade", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/processes/link")
|
||||
async def reconciliation_link_process(request: Request):
|
||||
form = await request.form()
|
||||
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
if opportunity_id and item_ids:
|
||||
try:
|
||||
count = link_reconciliation_process_to_opportunity(item_ids, opportunity_id)
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={count}%20itens%20de%20processo%20ligados", status_code=303)
|
||||
except Exception as exc: # pragma: no cover - production guard
|
||||
logger.exception("failed to link reconstructed reconciliation process to opportunity %s", opportunity_id)
|
||||
return RedirectResponse(
|
||||
"/reconciliation?notice=Erro%20ao%20ligar%20processo.%20Ver%20journal%20do%20servi%C3%A7o.",
|
||||
status_code=303,
|
||||
)
|
||||
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)
|
||||
|
||||
|
||||
async def _set_reconciliation_process_status(request: Request, *, status: str, note: str, notice: str):
|
||||
form = await request.form()
|
||||
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
|
||||
for item_id in item_ids:
|
||||
set_reconciliation_status(item_id, status=status, note=note)
|
||||
return RedirectResponse(f"/reconciliation?status={status}¬ice={notice}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/processes/needs-review")
|
||||
async def reconciliation_process_needs_review(request: Request):
|
||||
return await _set_reconciliation_process_status(
|
||||
request,
|
||||
status="needs_review",
|
||||
note="Processo enviado para revisão manual.",
|
||||
notice="Processo%20enviado%20para%20revis%C3%A3o",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reconciliation/processes/historical")
|
||||
async def reconciliation_process_historical(request: Request):
|
||||
return await _set_reconciliation_process_status(
|
||||
request,
|
||||
status="historical",
|
||||
note="Processo marcado como histórico sem ação operacional.",
|
||||
notice="Processo%20marcado%20como%20hist%C3%B3rico",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reconciliation/processes/ignore")
|
||||
async def reconciliation_process_ignore(request: Request):
|
||||
return await _set_reconciliation_process_status(
|
||||
request,
|
||||
status="ignored",
|
||||
note="Processo ignorado manualmente pelo operador.",
|
||||
notice="Processo%20ignorado",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reconciliation/{item_id}/needs-review")
|
||||
async def reconciliation_needs_review(item_id: str):
|
||||
set_reconciliation_status(item_id, status="needs_review", note="Enviado para revisão manual.")
|
||||
return RedirectResponse("/reconciliation?status=needs_review¬ice=Item%20enviado%20para%20revis%C3%A3o", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/{item_id}/ignore")
|
||||
async def reconciliation_ignore(item_id: str):
|
||||
set_reconciliation_status(item_id, status="ignored", note="Ignorado manualmente.")
|
||||
return RedirectResponse("/reconciliation?notice=Item%20ignorado", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/{item_id}/historical")
|
||||
async def reconciliation_historical(item_id: str):
|
||||
set_reconciliation_status(item_id, status="historical", note="Marcado como histórico sem ação operacional.")
|
||||
return RedirectResponse("/reconciliation?status=historical¬ice=Item%20marcado%20como%20hist%C3%B3rico", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/{item_id}/create-opportunity")
|
||||
async def reconciliation_create_opportunity(item_id: str):
|
||||
opportunity_id = create_opportunity_from_reconciliation(item_id)
|
||||
if opportunity_id:
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20criada%20a%20partir%20de%20reconciliação", status_code=303)
|
||||
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20criar%20oportunidade", status_code=303)
|
||||
|
||||
|
||||
@router.post("/reconciliation/{item_id}/link")
|
||||
async def reconciliation_link(item_id: str, request: Request):
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
if opportunity_id:
|
||||
link_reconciliation_to_opportunity(item_id, opportunity_id)
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20reconciliação%20ligado", status_code=303)
|
||||
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)
|
||||
57
app/admin_ui/pages/runs.py
Normal file
57
app/admin_ui/pages/runs.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Action run 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
async def runs_page(limit: int = 100):
|
||||
runs = list_action_runs(limit=limit)
|
||||
|
||||
rows = ""
|
||||
for run in runs:
|
||||
decision = run.get("action_decision") or {}
|
||||
result = run.get("action_result") or {}
|
||||
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td>{esc(run["created_at"])}</td>
|
||||
<td>#{esc(run.get("conversation_id"))}</td>
|
||||
<td>{esc(run.get("decision_source"))}</td>
|
||||
<td>{esc(decision.get("action_code"))}</td>
|
||||
<td>{esc(result.get("route"))}</td>
|
||||
<td>{esc(run.get("provider"))}</td>
|
||||
<td>{esc(run.get("total_tokens"))}</td>
|
||||
<td>{esc(run.get("cost"))}</td>
|
||||
<td>{status_badge("failed" if run.get("needs_review") else "sent")}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
body = f"""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Criado</th>
|
||||
<th>Conversa</th>
|
||||
<th>Fonte</th>
|
||||
<th>Ação</th>
|
||||
<th>Rota</th>
|
||||
<th>Provider</th>
|
||||
<th>Tokens</th>
|
||||
<th>Custo</th>
|
||||
<th>Review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return layout("Action Runs", "Histórico de decisões do Action Core.", body, "runs")
|
||||
|
||||
|
||||
300
app/admin_ui/pages/system.py
Normal file
300
app/admin_ui/pages/system.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Settings and system health 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
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/system", response_class=HTMLResponse)
|
||||
async def system_health_page():
|
||||
metrics = get_system_health_metrics()
|
||||
backup = latest_backup_info()
|
||||
|
||||
timers_raw = shell_output(["systemctl", "list-timers", "--all", "--no-pager"])
|
||||
clientflow_timers = "\n".join(
|
||||
line for line in timers_raw.splitlines()
|
||||
if "clientflow" in line.lower()
|
||||
)
|
||||
|
||||
services = [
|
||||
"clientflow-api.service",
|
||||
"clientflow-outbox.timer",
|
||||
"clientflow-backup.timer",
|
||||
"clientflow-followups.timer",
|
||||
]
|
||||
|
||||
service_rows = ""
|
||||
for service in services:
|
||||
active = shell_output(["systemctl", "is-active", service])
|
||||
enabled = shell_output(["systemctl", "is-enabled", service])
|
||||
ok = active in ["active", "inactive"] if service.endswith(".service") else active == "active"
|
||||
if service == "clientflow-api.service":
|
||||
ok = active == "active"
|
||||
|
||||
service_rows += f"""
|
||||
<tr>
|
||||
<td><code>{esc(service)}</code></td>
|
||||
<td>{status_pill(ok, active)}</td>
|
||||
<td>{esc(enabled)}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
def n(key):
|
||||
return metrics.get(key) or 0
|
||||
|
||||
db_ok = True
|
||||
outbox_ok = n("outbox_failed") == 0
|
||||
webhook_ok = n("webhooks_errors_24h") == 0
|
||||
backup_ok = bool(backup.get("exists"))
|
||||
|
||||
body = f"""
|
||||
<style>
|
||||
.system-grid {{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4, minmax(180px, 1fr));
|
||||
gap:14px;
|
||||
margin-bottom:18px;
|
||||
}}
|
||||
.system-card {{
|
||||
background:white;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:16px;
|
||||
padding:16px;
|
||||
}}
|
||||
.system-card span {{
|
||||
color:#6b7280;
|
||||
font-size:13px;
|
||||
}}
|
||||
.system-card strong {{
|
||||
display:block;
|
||||
font-size:28px;
|
||||
margin-top:6px;
|
||||
}}
|
||||
.system-panel {{
|
||||
background:white;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:16px;
|
||||
padding:16px;
|
||||
margin-bottom:16px;
|
||||
}}
|
||||
.system-panel h2 {{
|
||||
margin-top:0;
|
||||
font-size:18px;
|
||||
}}
|
||||
.system-pill {{
|
||||
display:inline-block;
|
||||
border-radius:999px;
|
||||
padding:4px 9px;
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
}}
|
||||
.pill-ok {{
|
||||
background:#dcfce7;
|
||||
color:#166534;
|
||||
border:1px solid #86efac;
|
||||
}}
|
||||
.pill-bad {{
|
||||
background:#fee2e2;
|
||||
color:#991b1b;
|
||||
border:1px solid #fecaca;
|
||||
}}
|
||||
pre.system-pre {{
|
||||
background:#111827;
|
||||
color:#f9fafb;
|
||||
border-radius:12px;
|
||||
padding:12px;
|
||||
overflow:auto;
|
||||
font-size:12px;
|
||||
}}
|
||||
@media (max-width:1000px) {{
|
||||
.system-grid {{ grid-template-columns:1fr 1fr; }}
|
||||
}}
|
||||
|
||||
</style>
|
||||
|
||||
<p>
|
||||
<a href="/">← Voltar à visão geral</a>
|
||||
·
|
||||
<a href="/system">Sistema</a>
|
||||
<a href="/tasks">Kanban</a>
|
||||
</p>
|
||||
|
||||
<div class="system-grid">
|
||||
<div class="system-card">
|
||||
<span>API</span>
|
||||
<strong>{status_pill(True, "OK")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Base de dados</span>
|
||||
<strong>{status_pill(db_ok, "OK" if db_ok else "ERRO")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Backups</span>
|
||||
<strong>{status_pill(backup_ok, "OK" if backup_ok else "FALTA")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Webhooks erros 24h</span>
|
||||
<strong>{n("webhooks_errors_24h")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-grid">
|
||||
<div class="system-card"><span>Tasks pendentes</span><strong>{n("tasks_pending")}</strong></div>
|
||||
<div class="system-card"><span>Tasks 24h</span><strong>{n("tasks_24h")}</strong></div>
|
||||
<div class="system-card"><span>Webhooks 24h</span><strong>{n("webhooks_24h")}</strong></div>
|
||||
<div class="system-card"><span>Outbox falhada</span><strong>{n("outbox_failed")}</strong></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Estado dos serviços/timers</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serviço</th>
|
||||
<th>Active</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{service_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Último backup</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Existe</th><td>{status_pill(backup_ok, "Sim" if backup_ok else "Não")}</td></tr>
|
||||
<tr><th>Ficheiro</th><td><code>{esc(backup.get("file"))}</code></td></tr>
|
||||
<tr><th>Tamanho</th><td>{esc(backup.get("size"))}</td></tr>
|
||||
<tr><th>Modificado</th><td>{esc(backup.get("mtime"))}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Timers ClientFlow</h2>
|
||||
<pre class="system-pre">{esc(clientflow_timers or "Sem timers ClientFlow encontrados.")}</pre>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Resumo técnico</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Total tasks</th><td>{n("tasks_total")}</td></tr>
|
||||
<tr><th>Tasks done</th><td>{n("tasks_done")}</td></tr>
|
||||
<tr><th>Tasks failed</th><td>{n("tasks_failed")}</td></tr>
|
||||
<tr><th>Webhooks ignorados 24h</th><td>{n("webhooks_ignored_24h")}</td></tr>
|
||||
<tr><th>Outbox pending</th><td>{n("outbox_pending")}</td></tr>
|
||||
<tr><th>Outbox sent</th><td>{n("outbox_sent")}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return layout("Sistema", "Estado técnico do ClientFlow e integrações.", body, "system")
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
@router.get("/configuracoes", response_class=HTMLResponse)
|
||||
async def settings_page():
|
||||
return RedirectResponse("/system", status_code=303)
|
||||
|
||||
|
||||
@router.get("/system/health", response_class=HTMLResponse)
|
||||
async def system_health_operational_page():
|
||||
health = get_system_health_summary()
|
||||
settings_info = health.get("settings") or {}
|
||||
timers = health.get("timers") or {}
|
||||
outbox = health.get("outbox") or {}
|
||||
documents = health.get("documents") or {}
|
||||
operational_metrics = health.get("operational_metrics") or {}
|
||||
|
||||
def m(key: str) -> int:
|
||||
try:
|
||||
return int(operational_metrics.get(key) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
timer_rows = ""
|
||||
for name, item in timers.items():
|
||||
ok = bool(item.get("ok"))
|
||||
chip = "cf-chip-green" if ok else "cf-chip-orange"
|
||||
timer_rows += f"""
|
||||
<tr><td>{esc(name)}</td><td><code>{esc(item.get('unit') or '—')}</code></td><td><span class="cf-chip {chip}">{esc(item.get('active_state') or 'unknown')}</span></td></tr>
|
||||
"""
|
||||
|
||||
outbox_rows = ""
|
||||
for target, by_status in outbox.items():
|
||||
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
|
||||
outbox_rows += f"<tr><td>{esc(target)}</td><td>{cells}</td></tr>"
|
||||
if not outbox_rows:
|
||||
outbox_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem dados de outbox.</td></tr>'
|
||||
|
||||
doc_rows = ""
|
||||
for kind, by_status in documents.items():
|
||||
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
|
||||
doc_rows += f"<tr><td>{esc(kind)}</td><td>{cells}</td></tr>"
|
||||
if not doc_rows:
|
||||
doc_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem documentos.</td></tr>'
|
||||
|
||||
db_ok = bool((health.get("database") or {}).get("ok"))
|
||||
critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed")
|
||||
warning_count = m("ambiguous_opportunity_tasks") + m("open_opportunities_without_fiscal_customer") + m("active_incomplete_fiscal_customers") + m("products_missing_external_code")
|
||||
if critical_count:
|
||||
production_state = "Crítico"
|
||||
production_class = "critical"
|
||||
production_hint = "corrigir bloqueios antes de automatizar"
|
||||
elif warning_count:
|
||||
production_state = "Atenção"
|
||||
production_class = "warn"
|
||||
production_hint = "existem validações operacionais pendentes"
|
||||
else:
|
||||
production_state = "OK"
|
||||
production_class = "ok"
|
||||
production_hint = "sem bloqueios críticos visíveis"
|
||||
|
||||
body = f"""
|
||||
<section class="cf-health-status {production_class} mb-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div><div class="small text-secondary fw-bold text-uppercase">Estado para operação</div><strong class="h3 mb-0">{production_state}</strong><div class="small text-secondary">{production_hint}</div></div>
|
||||
<div class="d-flex flex-wrap gap-2"><span class="cf-chip cf-chip-red">Críticos: {esc(critical_count)}</span><span class="cf-chip cf-chip-orange">Atenção: {esc(warning_count)}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Estado geral', esc(health.get('status')), None, 'OK' if db_ok else 'ver base de dados', 'bi-heart-pulse', 'cf-kpi-tone-green' if db_ok else 'cf-kpi-tone-red')}
|
||||
{kpi_card('Jasmin', 'ativo' if settings_info.get('jasmin_enabled') else 'inativo', None, f"{esc(settings_info.get('jasmin_company_key') or '—')} · {esc(settings_info.get('jasmin_quotation_serie') or '—')}", 'bi-receipt')}
|
||||
{kpi_card('Packlink', 'ativo' if settings_info.get('packlink_enabled') else 'inativo', None, f"serviço {esc(settings_info.get('packlink_default_service_id') or '—')}", 'bi-truck')}
|
||||
{kpi_card('Ambiente', esc(settings_info.get('env') or '—'), None, esc(settings_info.get('app_name') or 'ClientFlow'), 'bi-sliders')}
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid mt-3">
|
||||
{kpi_card('Tasks pendentes', esc(m('tasks_pending')), '/operations', f"done 24h: {esc(m('tasks_done_24h'))}", 'bi-list-task')}
|
||||
{kpi_card('Ambíguas', esc(m('ambiguous_opportunity_tasks')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-exclamation', 'cf-kpi-tone-orange' if m('ambiguous_opportunity_tasks') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Outbox stale', esc(m('outbox_stale') or m('outbox_processing_stale')), '/outbox?status=stale', f"modo: {esc(settings_info.get('outbox_stale_recovery_mode') or 'manual_only')}", 'bi-hourglass-split', 'cf-kpi-tone-red' if (m('outbox_stale') or m('outbox_processing_stale')) else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Ações operador 24h', esc(m('operator_actions_24h')), '/events', 'auditoria operacional', 'bi-shield-check')}
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid mt-3">
|
||||
{kpi_card('Oportunidades sem cliente fiscal', esc(m('open_opportunities_without_fiscal_customer')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-vcard', 'cf-kpi-tone-orange' if m('open_opportunities_without_fiscal_customer') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Clientes fiscais incompletos', esc(m('active_incomplete_fiscal_customers')), '/customers', 'em oportunidades ativas', 'bi-exclamation-diamond', 'cf-kpi-tone-orange' if m('active_incomplete_fiscal_customers') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Produtos sem código externo', esc(m('products_missing_external_code')), '/products', 'podem bloquear Jasmin', 'bi-box-seam', 'cf-kpi-tone-orange' if m('products_missing_external_code') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Último webhook Chatwoot', esc('—' if not m('seconds_since_last_chatwoot_webhook') else str(m('seconds_since_last_chatwoot_webhook')) + 's'), '/events', f"eventos: {esc(m('chatwoot_events_total'))}", 'bi-chat-dots')}
|
||||
</section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Timers systemd</h2><div class="small text-secondary">Estado best-effort dos timers da outbox.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Integração</th><th>Unidade</th><th>Estado</th></tr></thead><tbody>{timer_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Outbox por sistema</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Sistema</th><th>Estados</th></tr></thead><tbody>{outbox_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Documentos comerciais</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Estados</th></tr></thead><tbody>{doc_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body"><h2 class="cf-section-title mb-3">Base de dados</h2><p class="mb-2">Estado: <span class="cf-chip {'cf-chip-green' if db_ok else 'cf-chip-red'}">{'OK' if db_ok else 'Erro'}</span></p><pre class="cf-copy-box">{esc((health.get('database') or {}).get('error') or 'Sem erros reportados.')}</pre></div></section></div>
|
||||
</div>
|
||||
"""
|
||||
return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system")
|
||||
|
||||
|
||||
769
app/admin_ui/pages/tasks.py
Normal file
769
app/admin_ui/pages/tasks.py
Normal 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user