From 4b0e8c314ebd4726451ec81fea170cc4200d4384 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Thu, 23 Apr 2026 20:49:24 -0300 Subject: [PATCH] feat(aggressive-alert): escalada amarelo/laranja/vermelho + toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner agressivo passa de uma notificação só ("status→open") pra um sistema de escalada baseado em inatividade quando o cliente é o último a falar. Níveis: - 5 min sem resposta → AMARELO, sem som - 15 min sem resposta → LARANJA, beep 1x + notificação do SO - 28 min sem resposta → VERMELHO pulsante + som em loop infinito - status→open (reabertura) → VERMELHO imediato Por conversa, o banner mostra um item com nome do contato, inbox e contexto ("reabriu agora" / "15 min sem resposta"). Headline grande e explicação clara sobre como limpa. Comportamento do × dismiss: - Antes: apagava o alerta de vez. Agente podia "fingir que viu". - Agora: esconde temporariamente. Volta quando escalar (próximo threshold) ou nova mensagem. A única forma de LIMPAR de vez é responder o cliente (tracker detecta msg outgoing do User ou AgentBot e chama dismissForReply). Permissões: - account.settings.aggressive_alert_enabled (master switch admin) - user.ui_settings.aggressive_alert_enabled (toggle do próprio agente) - Default true pros dois; um false em qualquer bloqueia alertas. Settings UI: - Conta → General: novo card "Alerta agressivo (master switch)" - Perfil do usuário: novo card "Receber alertas agressivos" Arquivos: - helper/aggressiveAlert.js: multi-level state, hide vs dismiss-for-reply - helper/inactivityAlertTracker.js: timer único, thresholds declarativos - helper/actionCable.js: hook em onMessageCreated (feed tracker) + isAggressiveAlertEnabled() + limpa tracker em status_changed != open - components/app/AggressiveConversationBanner.vue: variantes de cor, headline grande, explanation, × temp-hide - account.rb + accounts_controller.rb: store_accessor + permitted - settings UI components (account + profile): switches auto-persist Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/api/v1/accounts_controller.rb | 3 +- .../app/AggressiveConversationBanner.vue | 249 +++++++++++------- .../dashboard/helper/actionCable.js | 85 +++++- .../dashboard/helper/aggressiveAlert.js | 242 +++++++++++++---- .../helper/inactivityAlertTracker.js | 96 +++++++ .../i18n/locale/en/aggressiveBanner.json | 20 +- .../i18n/locale/en/generalSettings.json | 8 + .../dashboard/i18n/locale/en/settings.json | 6 + .../i18n/locale/pt_BR/aggressiveBanner.json | 20 +- .../i18n/locale/pt_BR/generalSettings.json | 8 + .../dashboard/i18n/locale/pt_BR/settings.json | 6 + .../dashboard/settings/account/Index.vue | 3 + .../components/AggressiveAlertSetting.vue | 49 ++++ .../profile/AggressiveAlertProfileSetting.vue | 37 +++ .../dashboard/settings/profile/Index.vue | 8 + app/models/account.rb | 1 + 16 files changed, 664 insertions(+), 177 deletions(-) create mode 100644 app/javascript/dashboard/helper/inactivityAlertTracker.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/account/components/AggressiveAlertSetting.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/AggressiveAlertProfileSetting.vue diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bcbf80355..a5ef309c5 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -96,7 +96,8 @@ class Api::V1::AccountsController < Api::BaseController end def permitted_settings_attributes - [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label] + [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, + :aggressive_alert_enabled] end def check_signup_enabled diff --git a/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue index 76dd19469..b7df23925 100644 --- a/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue +++ b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue @@ -9,6 +9,7 @@ export default { data() { return { alerts: [], + maxLevel: null, }; }, computed: { @@ -16,38 +17,74 @@ export default { hasAlerts() { return this.alerts.length > 0; }, - bannerLabel() { - if (this.alerts.length === 1) { + bannerClass() { + return [ + 'aggressive-banner', + this.maxLevel ? `aggressive-banner--${this.maxLevel}` : '', + ]; + }, + bannerHeadline() { + const count = this.alerts.length; + if (count === 1) { + const a = this.alerts[0]; + if (a.kind === 'reopened') { + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_REOPENED', + 'Conversa reaberta — responda agora' + ); + } + // inactivity — mostra tempo + if (a.minutes >= 28) { + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_28', + { minutes: a.minutes }, + `🚨 ${a.minutes} MIN SEM RESPOSTA — conversa fecha em breve` + ); + } + if (a.minutes >= 15) { + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_15', + { minutes: a.minutes }, + `⚠️ ${a.minutes} MIN SEM RESPOSTA` + ); + } return this.$t( - 'AGGRESSIVE_CONVERSATION_BANNER.SINGLE', - 'Conversa aguardando resposta!' + 'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_5', + { minutes: a.minutes }, + `⏰ ${a.minutes} min sem resposta` ); } return this.$t( - 'AGGRESSIVE_CONVERSATION_BANNER.MULTIPLE', - { count: this.alerts.length }, - `${this.alerts.length} conversas aguardando resposta!` + 'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_MULTIPLE', + { count }, + `🚨 ${count} conversas aguardando resposta` + ); + }, + explanation() { + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.EXPLANATION', + 'Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente.' ); }, }, mounted() { - emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.handleTrigger); - emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.handleDismiss); + emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts); + emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts); // Rehidrata se alertas foram disparados antes do componente montar - this.alerts = aggressiveAlert.getActiveConversations(); + this.refreshAlerts(); }, beforeUnmount() { - emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.handleTrigger); - emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.handleDismiss); + emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts); + emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts); }, methods: { - handleTrigger() { - this.alerts = aggressiveAlert.getActiveConversations(); - }, - handleDismiss() { + refreshAlerts() { this.alerts = aggressiveAlert.getActiveConversations(); + this.maxLevel = aggressiveAlert.getMaxLevel(); }, openConversation(alert) { + // Clica no item → abre conversa E esconde o alerta dela (mas se + // não responder, volta a aparecer no próximo threshold). aggressiveAlert.dismiss(alert.id); if (!this.currentAccountId) return; this.$router.push({ @@ -58,40 +95,42 @@ export default { dismissOne(alert) { aggressiveAlert.dismiss(alert.id); }, - dismissAll() { - aggressiveAlert.dismissAll(); + alertItemClass(alert) { + return [ + 'aggressive-banner__item', + `aggressive-banner__item--${alert.level}`, + ]; + }, + alertContextLabel(alert) { + if (alert.kind === 'reopened') { + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.KIND_REOPENED', + 'reabriu' + ); + } + return this.$t( + 'AGGRESSIVE_CONVERSATION_BANNER.KIND_WAITING', + { minutes: alert.minutes || '?' }, + `${alert.minutes || '?'} min sem resposta` + ); }, }, };