diff --git a/app/action_catalog.py b/app/action_catalog.py index 937d81d..49cfccf 100644 --- a/app/action_catalog.py +++ b/app/action_catalog.py @@ -23,6 +23,7 @@ TRIAGE_ACTION_CODES = { } INTERNAL_ACTION_CODES = { + "CALL_CUSTOMER", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", @@ -40,6 +41,13 @@ INTERNAL_ACTION_CODES = { ACTION_CODES = TRIAGE_ACTION_CODES | INTERNAL_ACTION_CODES ACTION_MAP = { + "CALL_CUSTOMER": { + "route": "vendas", + "action_required": True, + "action": "Ligar ao cliente", + "safe_to_post": False, + "business_event_on_done": "customer_called", + }, "SEND_QUOTE": { "route": "vendas", "action_required": True, diff --git a/app/admin_dashboard.py b/app/admin_dashboard.py index 8929130..37525c8 100644 --- a/app/admin_dashboard.py +++ b/app/admin_dashboard.py @@ -286,6 +286,7 @@ Obrigado pela sua mensagem. Vamos analisar o pedido e responder assim que possível. {closing}""") ACTION_UI_LABELS = { + "CALL_CUSTOMER": "Ligar ao cliente", "SEND_INFO": "Enviar informação", "SEND_QUOTE": "Enviar orçamento", "SEND_PROFORMA": "Enviar orçamento para pagamento", diff --git a/app/admin_ui/labels.py b/app/admin_ui/labels.py index 0f10630..1b208dd 100644 --- a/app/admin_ui/labels.py +++ b/app/admin_ui/labels.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any ACTION_LABELS = { + "CALL_CUSTOMER": "Ligar ao cliente", "SEND_INFO": "Enviar informação", "SEND_QUOTE": "Preparar orçamento", "SEND_PROFORMA": "Enviar orçamento para pagamento", @@ -37,6 +38,7 @@ ACTION_LABELS = { } PRIMARY_ACTION_LABELS = { + "CALL_CUSTOMER": "Ligar ao cliente", "SEND_INFO": "Preparar resposta", "SEND_QUOTE": "Preparar orçamento", "SEND_PROFORMA": "Preparar orçamento para pagamento", diff --git a/app/admin_ui/pages/opportunities.py b/app/admin_ui/pages/opportunities.py index ad51e4d..6f93f52 100644 --- a/app/admin_ui/pages/opportunities.py +++ b/app/admin_ui/pages/opportunities.py @@ -18,6 +18,7 @@ from datetime import datetime, timezone import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.labels import primary_action_label +from app.timezone_utils import format_operator_datetime, parse_operator_local_datetime from app.operation_noise import is_noise_operation_item from app.opportunity_next_action_service import get_opportunity_next_action, get_opportunity_next_actions from app.work_center_action_policy import ( @@ -1543,11 +1544,19 @@ def _parse_opportunity_dt(value: object): def _opportunity_lifecycle_state(opp: dict) -> str: state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active" now = datetime.now(timezone.utc) + pending_call = str(opp.get("pending_follow_up_action_code") or "").strip().upper() == "CALL_CUSTOMER" + pending_call_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at")) + if pending_call: + return "follow_up_due" if pending_call_due and pending_call_due <= now else "scheduled_follow_up" + # Compatibility only: a stale denormalized lifecycle marker cannot invent + # a scheduled call when no active CALL_CUSTOMER task exists. + if state == "scheduled_follow_up": + return "active" nurture_until = _parse_opportunity_dt(opp.get("nurture_until")) next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at")) if state == "nurture" and nurture_until and nurture_until <= now: return "follow_up_due" - if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now: + if state in {"awaiting_customer", "active", "scheduled_follow_up"} and next_follow_up and next_follow_up <= now: return "follow_up_due" return state @@ -1705,6 +1714,9 @@ def _opportunity_card_next_action(opp: dict) -> str: return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido") if lifecycle_state == "nurture": return pending_follow_up_action or "Aguardar data para retomar contacto" + if lifecycle_state == "scheduled_follow_up": + due = opp.get("pending_follow_up_due_at") + return f"Ligar ao cliente em {format_operator_datetime(due)}" if due else "Ligar ao cliente" if lifecycle_state == "awaiting_customer": due = _parse_opportunity_dt(opp.get("next_follow_up_at")) return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente" @@ -2041,13 +2053,13 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N lifecycle_task = next(( task for task in pending_tasks if str(task.get("action_code") or "").upper() in { - "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", + "CALL_CUSTOMER", "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", } ), None) lifecycle_override = False - if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}: + if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "scheduled_follow_up", "recovery", "nurture"}: lifecycle_override = True next_action = { "action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"), @@ -2305,9 +2317,28 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N ''' + scheduled_call_task = next(( + task for task in pending_tasks + if str(task.get("action_code") or "").upper() == "CALL_CUSTOMER" + ), None) + scheduled_call_cancel_html = ( + f'
' + '
' + if scheduled_call_task else "" + ) manual_follow_up_html = f'''
-

Criar follow-up

+

Agendar contacto

+
Agenda uma chamada sem alterar a fase comercial da oportunidade.
+
+ + + + + +
+ {scheduled_call_cancel_html} +

Outros follow-ups

Agenda uma tarefa de follow-up. O sistema não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.