feat(aggressive-alert): escalada amarelo/laranja/vermelho + toggles
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) <noreply@anthropic.com>
This commit is contained in:
parent
f35c3ea821
commit
4b0e8c314e
@ -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
|
||||
|
||||
@ -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`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasAlerts"
|
||||
class="aggressive-banner"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="aggressive-banner__main">
|
||||
<span class="aggressive-banner__icon">{{
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.ALERT_ICON', '🚨')
|
||||
}}</span>
|
||||
<span class="aggressive-banner__title">{{ bannerLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__dismiss-all"
|
||||
@click="dismissAll"
|
||||
>
|
||||
{{
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.DISMISS_ALL', 'Dispensar todas')
|
||||
}}
|
||||
</button>
|
||||
<div v-if="hasAlerts" :class="bannerClass" role="alert" aria-live="assertive">
|
||||
<div class="aggressive-banner__headline">
|
||||
{{ bannerHeadline }}
|
||||
</div>
|
||||
<div class="aggressive-banner__explanation">
|
||||
{{ explanation }}
|
||||
</div>
|
||||
<ul class="aggressive-banner__list">
|
||||
<li
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="aggressive-banner__item"
|
||||
:class="alertItemClass(alert)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@ -104,25 +143,25 @@ export default {
|
||||
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
|
||||
· {{ alert.inboxName }}
|
||||
</span>
|
||||
<span v-if="alert.previousStatus" class="aggressive-banner__previous">
|
||||
·
|
||||
{{
|
||||
$t(
|
||||
`AGGRESSIVE_CONVERSATION_BANNER.FROM_${alert.previousStatus.toUpperCase()}`,
|
||||
alert.previousStatus
|
||||
)
|
||||
}}
|
||||
<span class="aggressive-banner__context">
|
||||
· {{ alertContextLabel(alert) }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__close"
|
||||
:aria-label="
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.DISMISS_ONE', 'Dispensar')
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE', 'Esconder')
|
||||
"
|
||||
:title="
|
||||
$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE_TITLE',
|
||||
'Esconde temporariamente — volta se não responder'
|
||||
)
|
||||
"
|
||||
@click="dismissOne(alert)"
|
||||
>
|
||||
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.CLOSE_ICON', '×') }}
|
||||
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ICON', '×') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@ -130,10 +169,28 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes aggressive-pulse {
|
||||
@keyframes aggressive-pulse-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #b91c1c;
|
||||
background-color: #eab308;
|
||||
}
|
||||
50% {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-orange {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #c2410c;
|
||||
}
|
||||
50% {
|
||||
background-color: #f97316;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-red {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #991b1b;
|
||||
}
|
||||
50% {
|
||||
background-color: #ef4444;
|
||||
@ -145,45 +202,38 @@ export default {
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
background-color: #b91c1c;
|
||||
color: #ffffff;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
animation: aggressive-pulse 1.2s ease-in-out infinite;
|
||||
font-weight: 600;
|
||||
padding: 14px 20px;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aggressive-banner__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
.aggressive-banner--yellow {
|
||||
background-color: #eab308;
|
||||
color: #1f2937;
|
||||
}
|
||||
.aggressive-banner--orange {
|
||||
background-color: #c2410c;
|
||||
animation: aggressive-pulse-orange 1.4s ease-in-out infinite;
|
||||
}
|
||||
.aggressive-banner--red {
|
||||
background-color: #991b1b;
|
||||
animation: aggressive-pulse-red 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.aggressive-banner__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
.aggressive-banner__headline {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.aggressive-banner__title {
|
||||
font-size: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aggressive-banner__dismiss-all {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
.aggressive-banner__explanation {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.aggressive-banner__dismiss-all:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
opacity: 0.92;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aggressive-banner__list {
|
||||
@ -198,51 +248,52 @@ export default {
|
||||
.aggressive-banner__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aggressive-banner__item--yellow {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.aggressive-banner__open {
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aggressive-banner__open:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.aggressive-banner__contact {
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.aggressive-banner__inbox,
|
||||
.aggressive-banner__previous {
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
.aggressive-banner__context {
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aggressive-banner__close {
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 10px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.aggressive-banner__close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import AuthAPI from '../api/auth';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import aggressiveAlert from './aggressiveAlert';
|
||||
import inactivityAlertTracker from './inactivityAlertTracker';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
@ -108,6 +109,69 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
lastActivityAt,
|
||||
conversationId,
|
||||
});
|
||||
this.feedInactivityTracker(data);
|
||||
};
|
||||
|
||||
// Alimenta o tracker de inatividade:
|
||||
// - Cliente (Contact) mandou mensagem em conversa open → começa a contar
|
||||
// - Agente (User/AgentBot/Captain) mandou mensagem → limpa (agente respondeu)
|
||||
// - Status deixou de ser open → trata como "resolvido", limpa
|
||||
feedInactivityTracker = data => {
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
const {
|
||||
conversation_id: conversationId,
|
||||
message_type: messageType,
|
||||
sender_type: senderType,
|
||||
conversation,
|
||||
} = data;
|
||||
|
||||
// message_type: 0=incoming, 1=outgoing, 2=activity, 3=template
|
||||
// Activity = evento do sistema (status mudou, etc). Ignora.
|
||||
if (messageType === 2 || messageType === 'activity') return;
|
||||
|
||||
// Incoming (cliente) e conversa aberta → começa/renova tracker
|
||||
const isIncoming = messageType === 0 || messageType === 'incoming';
|
||||
const conversationStatus = conversation && conversation.status;
|
||||
if (isIncoming && conversationStatus === 'open') {
|
||||
const contactName =
|
||||
conversation && conversation.meta && conversation.meta.sender
|
||||
? conversation.meta.sender.name
|
||||
: '';
|
||||
const inbox = this.app.$store.getters['inboxes/getInbox']
|
||||
? this.app.$store.getters['inboxes/getInbox'](conversation.inbox_id)
|
||||
: null;
|
||||
const inboxName = inbox && inbox.name ? inbox.name : '';
|
||||
inactivityAlertTracker.onClientMessage({
|
||||
conversationId,
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Qualquer mensagem do agente/bot → limpa tracker
|
||||
if (senderType === 'User' || senderType === 'AgentBot') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
// Lê account.settings.aggressive_alert_enabled + user.ui_settings
|
||||
isAggressiveAlertEnabled = () => {
|
||||
const store = this.app.$store;
|
||||
const account = store.getters.getCurrentAccount;
|
||||
const user = store.getters.getCurrentUser;
|
||||
|
||||
// Default true se settings não vieram ainda (não bloqueia no boot).
|
||||
const accountEnabled =
|
||||
!account ||
|
||||
!account.settings ||
|
||||
account.settings.aggressive_alert_enabled !== false;
|
||||
const userEnabled =
|
||||
!user ||
|
||||
!user.ui_settings ||
|
||||
user.ui_settings.aggressive_alert_enabled !== false;
|
||||
|
||||
return accountEnabled && userEnabled;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
@ -115,21 +179,21 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onStatusChange = data => {
|
||||
this.maybeTriggerAggressiveAlert(data);
|
||||
// Se saiu de 'open' (resolvida/snoozada/pending), limpa qualquer alerta
|
||||
// pendente pra essa conversa.
|
||||
if (data && data.id && data.status && data.status !== 'open') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(data.id);
|
||||
}
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
// Dispara banner + som + push do SO toda vez que conversa transita
|
||||
// pra 'open'. Broadcast `conversation.status_changed` só chega em
|
||||
// mudança real de status, então confiar no evento é suficiente.
|
||||
//
|
||||
// Não usar store.getters.getConversationById(id).status pra detectar
|
||||
// transição: quando o próprio usuário reabre, o dispatch HTTP local
|
||||
// (toggleStatus→CHANGE_CONVERSATION_STATUS) já mutou o store antes do
|
||||
// broadcast chegar → previousStatus === 'open' bloqueava o alerta.
|
||||
// Conversas novas entram via onConversationCreated, não por status_changed.
|
||||
// Dispara banner RED toda vez que a conversa transita pra 'open'.
|
||||
// Broadcast `conversation.status_changed` só chega em mudança real,
|
||||
// então confiar no evento é suficiente.
|
||||
maybeTriggerAggressiveAlert = data => {
|
||||
if (!data || data.status !== 'open') return;
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
const store = this.app.$store;
|
||||
const contactName =
|
||||
data.meta && data.meta.sender ? data.meta.sender.name : '';
|
||||
@ -140,9 +204,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
aggressiveAlert.trigger({
|
||||
conversationId: data.id,
|
||||
level: 'red',
|
||||
kind: 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
previousStatus: '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -6,6 +6,19 @@ const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
|
||||
const TITLE_FLASH_INTERVAL_MS = 1000;
|
||||
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
|
||||
|
||||
// Níveis de severidade — ordem numérica cresce com a urgência.
|
||||
export const LEVEL = {
|
||||
YELLOW: 'yellow',
|
||||
ORANGE: 'orange',
|
||||
RED: 'red',
|
||||
};
|
||||
|
||||
const LEVEL_SEVERITY = {
|
||||
[LEVEL.YELLOW]: 1,
|
||||
[LEVEL.ORANGE]: 2,
|
||||
[LEVEL.RED]: 3,
|
||||
};
|
||||
|
||||
const showOSNotification = (title, body) => {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
@ -36,17 +49,19 @@ class AggressiveAlertManager {
|
||||
this.audio = null;
|
||||
this.titleInterval = null;
|
||||
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
|
||||
// Map<conversationId, { level, kind, contactName, inboxName, minutes, triggeredAt, temporarilyHidden }>
|
||||
this.activeConversations = new Map();
|
||||
}
|
||||
|
||||
ensureAudio() {
|
||||
if (this.audio) return;
|
||||
this.audio = new Audio(ALERT_AUDIO_PATH);
|
||||
this.audio.loop = true;
|
||||
}
|
||||
|
||||
playSound() {
|
||||
// Som em loop infinito (usado pro nível RED — urgência máxima)
|
||||
playLoopSound() {
|
||||
this.ensureAudio();
|
||||
this.audio.loop = true;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {
|
||||
@ -55,18 +70,53 @@ class AggressiveAlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Som 1x (usado pro ORANGE — chama atenção mas não satura)
|
||||
playOnceSound() {
|
||||
// Se já está tocando em loop pra outro alerta, não interfere.
|
||||
if (this.hasLoopSound()) return;
|
||||
this.ensureAudio();
|
||||
this.audio.loop = false;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
hasLoopSound() {
|
||||
// Loop está ativo se algum alerta no map tem level === RED e não está hidden.
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry => entry.level === LEVEL.RED && !entry.temporarilyHidden
|
||||
);
|
||||
}
|
||||
|
||||
stopSound() {
|
||||
if (!this.audio) return;
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.loop = false;
|
||||
}
|
||||
|
||||
// O título pisca se existir pelo menos 1 alerta visível com level ORANGE ou RED.
|
||||
shouldFlashTitle() {
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry =>
|
||||
!entry.temporarilyHidden &&
|
||||
(entry.level === LEVEL.ORANGE || entry.level === LEVEL.RED)
|
||||
);
|
||||
}
|
||||
|
||||
countVisibleAlerts() {
|
||||
return Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
).length;
|
||||
}
|
||||
|
||||
updateTitleTick(toggle) {
|
||||
const count = this.activeConversations.size;
|
||||
if (count === 0) {
|
||||
if (!this.shouldFlashTitle()) {
|
||||
document.title = this.originalTitle;
|
||||
return;
|
||||
}
|
||||
const count = this.countVisibleAlerts();
|
||||
document.title = toggle
|
||||
? `🚨 (${count}) CONVERSA ABERTA`
|
||||
: this.originalTitle;
|
||||
@ -74,6 +124,7 @@ class AggressiveAlertManager {
|
||||
|
||||
startTitleFlash() {
|
||||
if (this.titleInterval) return;
|
||||
if (!this.shouldFlashTitle()) return;
|
||||
let toggle = false;
|
||||
this.updateTitleTick(true);
|
||||
this.titleInterval = setInterval(() => {
|
||||
@ -90,49 +141,126 @@ class AggressiveAlertManager {
|
||||
document.title = this.originalTitle;
|
||||
}
|
||||
|
||||
trigger({ conversationId, contactName, inboxName, previousStatus }) {
|
||||
if (!conversationId) return;
|
||||
if (this.activeConversations.has(conversationId)) return;
|
||||
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
|
||||
refreshOutputs() {
|
||||
const hasLoop = this.hasLoopSound();
|
||||
const shouldFlash = this.shouldFlashTitle();
|
||||
|
||||
this.activeConversations.set(conversationId, {
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
previousStatus: previousStatus || '',
|
||||
triggeredAt: Date.now(),
|
||||
});
|
||||
|
||||
this.playSound();
|
||||
showOSNotification(
|
||||
'🚨 Conversa aberta aguardando resposta',
|
||||
`${contactName || 'Cliente'} — ${inboxName || ''}`.trim()
|
||||
);
|
||||
vibrateDevice();
|
||||
this.startTitleFlash();
|
||||
|
||||
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, {
|
||||
conversationId,
|
||||
contactName,
|
||||
inboxName,
|
||||
previousStatus,
|
||||
total: this.activeConversations.size,
|
||||
});
|
||||
}
|
||||
|
||||
dismiss(conversationId) {
|
||||
if (!this.activeConversations.has(conversationId)) return;
|
||||
this.activeConversations.delete(conversationId);
|
||||
|
||||
if (this.activeConversations.size === 0) {
|
||||
this.stopSound();
|
||||
this.stopTitleFlash();
|
||||
if (hasLoop) {
|
||||
this.playLoopSound();
|
||||
} else {
|
||||
this.updateTitleTick(true);
|
||||
this.stopSound();
|
||||
}
|
||||
|
||||
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
|
||||
conversationId,
|
||||
total: this.activeConversations.size,
|
||||
if (shouldFlash) {
|
||||
this.startTitleFlash();
|
||||
} else {
|
||||
this.stopTitleFlash();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara ou escala um alerta.
|
||||
* @param {Object} opts
|
||||
* @param {number|string} opts.conversationId
|
||||
* @param {string} opts.level - LEVEL.YELLOW | LEVEL.ORANGE | LEVEL.RED
|
||||
* @param {string} opts.kind - 'reopened' | 'inactivity'
|
||||
* @param {string} [opts.contactName]
|
||||
* @param {string} [opts.inboxName]
|
||||
* @param {number} [opts.minutes] - só pra inactivity (5/15/28)
|
||||
*/
|
||||
trigger({
|
||||
conversationId,
|
||||
level = LEVEL.RED,
|
||||
kind = 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
minutes,
|
||||
}) {
|
||||
if (!conversationId) return;
|
||||
const existing = this.activeConversations.get(conversationId);
|
||||
|
||||
// Escalada: se já existe e o novo level é MENOS severo, ignora.
|
||||
// Se for mais severo, atualiza (ex: yellow → orange, inactivity).
|
||||
if (existing) {
|
||||
const currentSev = LEVEL_SEVERITY[existing.level] || 0;
|
||||
const incomingSev = LEVEL_SEVERITY[level] || 0;
|
||||
// Se o alerta tá "escondido temporariamente" e chegou novo, desesconde.
|
||||
if (incomingSev >= currentSev || existing.temporarilyHidden) {
|
||||
this.activeConversations.set(conversationId, {
|
||||
...existing,
|
||||
level: incomingSev > currentSev ? level : existing.level,
|
||||
kind: incomingSev > currentSev ? kind : existing.kind,
|
||||
minutes: incomingSev > currentSev ? minutes : existing.minutes,
|
||||
contactName: contactName || existing.contactName,
|
||||
inboxName: inboxName || existing.inboxName,
|
||||
temporarilyHidden: false,
|
||||
triggeredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.activeConversations.set(conversationId, {
|
||||
level,
|
||||
kind,
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
minutes: minutes || null,
|
||||
triggeredAt: Date.now(),
|
||||
temporarilyHidden: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Som por nível
|
||||
if (level === LEVEL.RED) {
|
||||
this.playLoopSound();
|
||||
} else if (level === LEVEL.ORANGE) {
|
||||
this.playOnceSound();
|
||||
}
|
||||
// YELLOW: sem som
|
||||
|
||||
if (level === LEVEL.ORANGE || level === LEVEL.RED) {
|
||||
showOSNotification(
|
||||
'🚨 Conversa aguardando resposta',
|
||||
`${contactName || 'Cliente'} — ${inboxName || ''}`.trim()
|
||||
);
|
||||
vibrateDevice();
|
||||
}
|
||||
|
||||
this.startTitleFlash();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* × — dismiss temporário. Remove do visual mas mantém no map como "hidden".
|
||||
* Volta a aparecer se escalar (receber mais severo) ou receber nova mensagem.
|
||||
* Pra limpar de verdade, o agente tem que responder (então o tracker chama
|
||||
* dismissForReply).
|
||||
*/
|
||||
hide(conversationId) {
|
||||
const entry = this.activeConversations.get(conversationId);
|
||||
if (!entry) return;
|
||||
this.activeConversations.set(conversationId, {
|
||||
...entry,
|
||||
temporarilyHidden: true,
|
||||
});
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss definitivo — chamado quando o agente respondeu ou o tracker
|
||||
* detectou que o cliente não é mais o último a mandar.
|
||||
*/
|
||||
dismissForReply(conversationId) {
|
||||
if (!this.activeConversations.has(conversationId)) return;
|
||||
this.activeConversations.delete(conversationId);
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
// Mesmo que hide, mas pra API pública (botão × do banner)
|
||||
dismiss(conversationId) {
|
||||
this.hide(conversationId);
|
||||
}
|
||||
|
||||
dismissAll() {
|
||||
@ -140,17 +268,33 @@ class AggressiveAlertManager {
|
||||
this.activeConversations.clear();
|
||||
this.stopSound();
|
||||
this.stopTitleFlash();
|
||||
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
|
||||
conversationId: null,
|
||||
total: 0,
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, null);
|
||||
}
|
||||
|
||||
emitBusEvent(event, conversationId) {
|
||||
emitter.emit(event, {
|
||||
conversationId,
|
||||
total: this.countVisibleAlerts(),
|
||||
});
|
||||
}
|
||||
|
||||
getActiveConversations() {
|
||||
return Array.from(this.activeConversations.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
...data,
|
||||
}));
|
||||
return Array.from(this.activeConversations.entries())
|
||||
.filter(([, data]) => !data.temporarilyHidden)
|
||||
.map(([id, data]) => ({ id, ...data }));
|
||||
}
|
||||
|
||||
// Level mais alto entre os alertas visíveis — o banner usa pra cor do wrapper.
|
||||
getMaxLevel() {
|
||||
const visible = Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
);
|
||||
if (visible.length === 0) return null;
|
||||
return visible.reduce((winner, entry) => {
|
||||
const sevWinner = LEVEL_SEVERITY[winner] || 0;
|
||||
const sevEntry = LEVEL_SEVERITY[entry.level] || 0;
|
||||
return sevEntry > sevWinner ? entry.level : winner;
|
||||
}, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
app/javascript/dashboard/helper/inactivityAlertTracker.js
Normal file
96
app/javascript/dashboard/helper/inactivityAlertTracker.js
Normal file
@ -0,0 +1,96 @@
|
||||
import aggressiveAlert, { LEVEL } from './aggressiveAlert';
|
||||
|
||||
// Thresholds de inatividade. Cada um dispara UMA vez por conversa (enquanto
|
||||
// o cliente segue sendo o último a falar). Ordem: do menos urgente ao mais.
|
||||
const THRESHOLDS = [
|
||||
{ minutes: 5, level: LEVEL.YELLOW },
|
||||
{ minutes: 15, level: LEVEL.ORANGE },
|
||||
{ minutes: 28, level: LEVEL.RED },
|
||||
];
|
||||
|
||||
// Checa o estado dos alertas a cada 20s — granularidade suficiente pra
|
||||
// não perder threshold (a menor janela entre thresholds é 5min = 300s).
|
||||
const CHECK_INTERVAL_MS = 20_000;
|
||||
|
||||
class InactivityAlertTracker {
|
||||
constructor() {
|
||||
// Map<conversationId, { lastClientAt, firedMinutes: Set<number>, contactName, inboxName }>
|
||||
this.conversations = new Map();
|
||||
this.interval = null;
|
||||
this.enabledGetter = () => true; // injetado pelo actionCable com o store
|
||||
}
|
||||
|
||||
setEnabledGetter(fn) {
|
||||
this.enabledGetter = fn;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.interval) return;
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra ou atualiza que o CLIENTE mandou mensagem em uma conversa aberta.
|
||||
* Zera os thresholds se já existia (porque o relógio recomeça).
|
||||
*/
|
||||
onClientMessage({ conversationId, contactName, inboxName }) {
|
||||
if (!conversationId) return;
|
||||
this.conversations.set(conversationId, {
|
||||
lastClientAt: Date.now(),
|
||||
firedMinutes: new Set(),
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
});
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa a conversa — agente respondeu ou cenário não mais aplicável.
|
||||
* Também dá dismiss no banner pra parar som.
|
||||
*/
|
||||
onAgentReplyOrResolved(conversationId) {
|
||||
if (!conversationId) return;
|
||||
if (this.conversations.has(conversationId)) {
|
||||
this.conversations.delete(conversationId);
|
||||
}
|
||||
aggressiveAlert.dismissForReply(conversationId);
|
||||
if (this.conversations.size === 0) this.stop();
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.enabledGetter()) return;
|
||||
if (this.conversations.size === 0) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
Array.from(this.conversations.entries()).forEach(
|
||||
([conversationId, entry]) => {
|
||||
const elapsedMin = (now - entry.lastClientAt) / 60000;
|
||||
THRESHOLDS.forEach(t => {
|
||||
if (elapsedMin < t.minutes) return;
|
||||
if (entry.firedMinutes.has(t.minutes)) return;
|
||||
entry.firedMinutes.add(t.minutes);
|
||||
aggressiveAlert.trigger({
|
||||
conversationId,
|
||||
level: t.level,
|
||||
kind: 'inactivity',
|
||||
contactName: entry.contactName,
|
||||
inboxName: entry.inboxName,
|
||||
minutes: t.minutes,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inactivityAlertTracker = new InactivityAlertTracker();
|
||||
|
||||
export default inactivityAlertTracker;
|
||||
@ -1,13 +1,15 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"SINGLE": "Conversation waiting for a response!",
|
||||
"MULTIPLE": "{count} conversations waiting for a response!",
|
||||
"DISMISS_ALL": "Dismiss all",
|
||||
"DISMISS_ONE": "Dismiss",
|
||||
"CLOSE_ICON": "×",
|
||||
"ALERT_ICON": "🚨",
|
||||
"FROM_PENDING": "from pending",
|
||||
"FROM_SNOOZED": "reopened (snoozed)",
|
||||
"FROM_RESOLVED": "reopened (resolved)"
|
||||
"HEADLINE_REOPENED": "🚨 Conversation reopened — reply now",
|
||||
"HEADLINE_5": "⏰ {minutes} min without reply",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN WITHOUT REPLY — respond!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN WITHOUT REPLY — conversation will auto-close!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversations awaiting reply",
|
||||
"EXPLANATION": "This alert only clears when you REPLY to the conversation. Clicking × hides temporarily — it comes back if you do not reply.",
|
||||
"KIND_REOPENED": "just reopened",
|
||||
"KIND_WAITING": "{minutes} min without reply",
|
||||
"HIDE_ONE": "Hide",
|
||||
"HIDE_ONE_TITLE": "Hide temporarily — comes back if you do not reply",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +104,14 @@
|
||||
"ERROR": "Failed to update audio transcription setting"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Aggressive conversation alert (master switch)",
|
||||
"NOTE": "When on, agents receive a banner + sound + OS notification when a conversation is reopened and at 5/15/28 min without reply. Each agent can still turn it off in their profile — this is the account-wide master. Off here = nobody receives.",
|
||||
"API": {
|
||||
"SUCCESS": "Aggressive alert setting updated",
|
||||
"ERROR": "Failed to update aggressive alert setting"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Inactivity duration for resolution",
|
||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
||||
|
||||
@ -35,6 +35,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Aggressive conversation alert",
|
||||
"SECTION_NOTE": "Triggers a banner, sound and OS notification when a conversation is reopened and every 5/15/28 minutes without reply. Only clears when YOU reply. Turn off for a silent shift — but own the risk.",
|
||||
"TITLE": "Receive aggressive alerts",
|
||||
"NOTE": "When on, you get a banner + sound + notification when a conversation reopens or goes X minutes without reply. Turn off at your own risk."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"SINGLE": "Conversa aguardando resposta!",
|
||||
"MULTIPLE": "{count} conversas aguardando resposta!",
|
||||
"DISMISS_ALL": "Dispensar todas",
|
||||
"DISMISS_ONE": "Dispensar",
|
||||
"CLOSE_ICON": "×",
|
||||
"ALERT_ICON": "🚨",
|
||||
"FROM_PENDING": "saiu de pendente",
|
||||
"FROM_SNOOZED": "reaberta (estava adiada)",
|
||||
"FROM_RESOLVED": "reaberta (estava resolvida)"
|
||||
"HEADLINE_REOPENED": "🚨 Conversa reaberta — responda agora",
|
||||
"HEADLINE_5": "⏰ {minutes} min sem resposta",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN SEM RESPOSTA — responda!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN SEM RESPOSTA — conversa vai fechar!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversas aguardando resposta",
|
||||
"EXPLANATION": "Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente — volta se não responder.",
|
||||
"KIND_REOPENED": "reabriu agora",
|
||||
"KIND_WAITING": "{minutes} min sem resposta",
|
||||
"HIDE_ONE": "Esconder",
|
||||
"HIDE_ONE_TITLE": "Esconde temporariamente — volta se não responder",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +104,14 @@
|
||||
"ERROR": "Falha ao atualizar configuração de transcrição de áudio"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Alerta agressivo de conversa (master switch)",
|
||||
"NOTE": "Quando ligado, atendentes recebem banner + som + notificação do SO quando uma conversa é reaberta e a cada 5/15/28 min sem resposta. Cada agente ainda pode desligar pra si no próprio perfil — este toggle é o mestre da conta. Desligar aqui = ninguém recebe.",
|
||||
"API": {
|
||||
"SUCCESS": "Alerta agressivo atualizado",
|
||||
"ERROR": "Falha ao atualizar o alerta agressivo"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Tempo de inatividade para resolução",
|
||||
"HELP": "Tempo de inatividade após o qual a conversa deve ser encerrada automaticamente",
|
||||
|
||||
@ -35,6 +35,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Alerta agressivo de conversa",
|
||||
"SECTION_NOTE": "Ativa banner, som e notificação do SO quando uma conversa é reaberta e a cada 5/15/28 minutos sem resposta. Só some quando VOCÊ responder. Desativa se quiser turno silencioso — mas a casa cai se esquecer.",
|
||||
"TITLE": "Receber alertas agressivos",
|
||||
"NOTE": "Se ligado, você recebe banner + som + notificação quando uma conversa é reaberta ou fica X minutos sem resposta. Só desliga se souber o que está fazendo."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Personalize a aparência do seu painel do Chatwoot.",
|
||||
|
||||
@ -15,6 +15,7 @@ import AccountId from './components/AccountId.vue';
|
||||
import BuildInfo from './components/BuildInfo.vue';
|
||||
import AccountDelete from './components/AccountDelete.vue';
|
||||
import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import AggressiveAlertSetting from './components/AggressiveAlertSetting.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
@ -25,6 +26,7 @@ export default {
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AudioTranscription,
|
||||
AggressiveAlertSetting,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
@ -232,6 +234,7 @@ export default {
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AggressiveAlertSetting />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SectionLayout from './SectionLayout.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
// Default true — quando account ainda não carregou, assume ligado.
|
||||
const isEnabled = ref(true);
|
||||
|
||||
const { currentAccount, updateAccount } = useAccount();
|
||||
|
||||
watch(
|
||||
currentAccount,
|
||||
() => {
|
||||
const settings = currentAccount.value?.settings || {};
|
||||
// Só trata como false se explicitamente false; qualquer outro valor = ligado.
|
||||
isEnabled.value = settings.aggressive_alert_enabled !== false;
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
try {
|
||||
await updateAccount({
|
||||
aggressive_alert_enabled: isEnabled.value,
|
||||
});
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE')"
|
||||
:description="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE')"
|
||||
with-border
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggle" />
|
||||
</div>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
// Default true — só trata como false se estiver explicitamente como false.
|
||||
const isEnabled = computed({
|
||||
get() {
|
||||
return uiSettings.value?.aggressive_alert_enabled !== false;
|
||||
},
|
||||
set(value) {
|
||||
updateUISettings({ aggressive_alert_enabled: value });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-solid rounded-lg border-n-weak p-4 bg-n-solid-1 flex items-start gap-4"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-n-slate-12 mb-1">
|
||||
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 leading-normal">
|
||||
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<Switch v-model="isEnabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -20,6 +20,7 @@ import AudioNotifications from './AudioNotifications.vue';
|
||||
import FormSection from 'dashboard/components/FormSection.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import AggressiveAlertProfileSetting from './AggressiveAlertProfileSetting.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import {
|
||||
ROLES,
|
||||
@ -41,6 +42,7 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
AggressiveAlertProfileSetting,
|
||||
},
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
@ -242,6 +244,12 @@ export default {
|
||||
@update-user="updateProfile"
|
||||
/>
|
||||
</div>
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_NOTE')"
|
||||
>
|
||||
<AggressiveAlertProfileSetting />
|
||||
</FormSection>
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
|
||||
:description="
|
||||
|
||||
@ -90,6 +90,7 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :aggressive_alert_enabled
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
Loading…
Reference in New Issue
Block a user