SLA de conversas WhatsApp (Grupo 3.4): config + detecção + alerta
Completa o Grupo 3 do CRM com alerta de conversa sem resposta além do tempo configurado — reutiliza o pipeline system_alert (toast vermelho sticky + sininho + drawer). Banco (migration 20260423000005): - conversation_sla_rules: 1 linha por tenant com threshold global (1-1440 min), respect_business_hours, business_hours_start/end, business_days (ISO 1=seg..7=dom), alert_scope (assigned_only|all), notify_admin_on_breach. Default: enabled=false. - conversation_sla_breaches: incidents com UNIQUE parcial (tenant_id, thread_key) WHERE resolved_at IS NULL — idempotência. - Trigger AFTER INSERT em conversation_messages resolve o breach automaticamente quando chega nova outbound na thread. - RPCs service_role: sla_open_breach (idempotente), sla_mark_notified. - RLS: membros do tenant leem; clinic_admin/tenant_admin/saas_admin escrevem na config; service_role escreve em breaches. Edge function conversation-sla-check (cron 5min): - Varre tenants com enabled=true. - Query conversation_threads onde last_message_direction='inbound' (+ assigned_to NOT NULL se scope='assigned_only'). - Se respect_business_hours: calcula businessMinutesElapsed em TS iterando dia por dia a interseção da janela [start,end] com [last_inbound_at, now], só em dias marcados em business_days. TZ fixa em America/Sao_Paulo via Intl.DateTimeFormat. - Se elapsed >= threshold: sla_open_breach (idempotente) + notifica assigned_to sempre + admins se notify_admin_on_breach (deduplicado via Set). - Anti-spam: só notifica 1x por incident (checa notified_at). - Notification leva deeplink pra /crm/conversas e payload.thread_key pro frontend destacar a conversa (fora de escopo deste commit). UI em /configuracoes/conversas-sla: - Toggle enabled + InputNumber threshold com preview "≈ Xh Ymin". - Toggle respect_business_hours → revela start/end + seletor de dias úteis (pills toggleáveis Seg..Dom, ISO order). - Select scope. - Toggle notify_admin_on_breach. - Card abaixo com breaches dos últimos 7 dias (status aberto/resolvido, thread_key, limite configurado no momento do breach, duração). - Adicionada na ConfiguracoesPage landing + rota /configuracoes/conversas-sla. Cron template comentado no fim da migration (mesmo padrão do heartbeat). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,13 @@ const grupos = [
|
||||
icon: 'pi pi-ban',
|
||||
to: '/configuracoes/conversas-optouts'
|
||||
},
|
||||
{
|
||||
key: 'conversas-sla',
|
||||
label: 'SLA de resposta',
|
||||
desc: 'Tempo máximo pra responder mensagens de pacientes. Alerta quando estourar.',
|
||||
icon: 'pi pi-stopwatch',
|
||||
to: '/configuracoes/conversas-sla'
|
||||
},
|
||||
{
|
||||
key: 'lembretes-sessao',
|
||||
label: 'Lembretes de Sessão',
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SLA de conversas (CRM Grupo 3.4)
|
||||
|--------------------------------------------------------------------------
|
||||
| Configura tempo máximo de resposta a mensagens de pacientes. Quando
|
||||
| uma conversa fica sem resposta além do threshold, o terapeuta atribuído
|
||||
| recebe alerta (toast vermelho + sininho) e opcionalmente o admin da clínica.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const DEFAULT_RULE = {
|
||||
enabled: false,
|
||||
threshold_minutes: 60,
|
||||
respect_business_hours: true,
|
||||
business_hours_start: '08:00',
|
||||
business_hours_end: '18:00',
|
||||
business_days: [1, 2, 3, 4, 5],
|
||||
alert_scope: 'assigned_only',
|
||||
notify_admin_on_breach: false
|
||||
};
|
||||
|
||||
const DIAS_ISO = [
|
||||
{ iso: 1, label: 'Seg' },
|
||||
{ iso: 2, label: 'Ter' },
|
||||
{ iso: 3, label: 'Qua' },
|
||||
{ iso: 4, label: 'Qui' },
|
||||
{ iso: 5, label: 'Sex' },
|
||||
{ iso: 6, label: 'Sáb' },
|
||||
{ iso: 7, label: 'Dom' }
|
||||
];
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'assigned_only', label: 'Apenas conversas atribuídas' },
|
||||
{ value: 'all', label: 'Todas as conversas (incluindo não atribuídas)' }
|
||||
];
|
||||
|
||||
const rule = ref({ ...DEFAULT_RULE });
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const breaches = ref([]);
|
||||
const breachesLoading = ref(false);
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId);
|
||||
|
||||
// Converte TIME do DB ('08:00:00') → string 'HH:MM' pra time picker
|
||||
function timeFromDb(t) {
|
||||
return String(t || '').slice(0, 5);
|
||||
}
|
||||
// 'HH:MM' → 'HH:MM:00' pra DB
|
||||
function timeToDb(t) {
|
||||
const clean = String(t || '').trim();
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) return `${clean}:00`;
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(clean)) return clean;
|
||||
return '08:00:00';
|
||||
}
|
||||
|
||||
async function loadRule() {
|
||||
if (!tenantId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_sla_rules')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (data) {
|
||||
rule.value = {
|
||||
enabled: !!data.enabled,
|
||||
threshold_minutes: Number(data.threshold_minutes) || 60,
|
||||
respect_business_hours: !!data.respect_business_hours,
|
||||
business_hours_start: timeFromDb(data.business_hours_start),
|
||||
business_hours_end: timeFromDb(data.business_hours_end),
|
||||
business_days: Array.isArray(data.business_days) ? [...data.business_days] : [1, 2, 3, 4, 5],
|
||||
alert_scope: data.alert_scope || 'assigned_only',
|
||||
notify_admin_on_breach: !!data.notify_admin_on_breach
|
||||
};
|
||||
} else {
|
||||
rule.value = { ...DEFAULT_RULE, business_days: [...DEFAULT_RULE.business_days] };
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar regra', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDay(iso) {
|
||||
const arr = rule.value.business_days || [];
|
||||
const idx = arr.indexOf(iso);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
else arr.push(iso);
|
||||
arr.sort((a, b) => a - b);
|
||||
rule.value.business_days = [...arr];
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const r = rule.value;
|
||||
if (!r.threshold_minutes || r.threshold_minutes < 1 || r.threshold_minutes > 1440) {
|
||||
toast.add({ severity: 'warn', summary: 'Threshold inválido', detail: 'Use entre 1 e 1440 minutos (24h).', life: 3500 });
|
||||
return false;
|
||||
}
|
||||
if (r.respect_business_hours) {
|
||||
if (!r.business_days || r.business_days.length === 0) {
|
||||
toast.add({ severity: 'warn', summary: 'Selecione ao menos um dia útil', life: 3500 });
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{2}:\d{2}$/.test(r.business_hours_start) || !/^\d{2}:\d{2}$/.test(r.business_hours_end)) {
|
||||
toast.add({ severity: 'warn', summary: 'Horário inválido', detail: 'Formato: HH:MM', life: 3500 });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
if (!tenantId.value) return;
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
tenant_id: tenantId.value,
|
||||
enabled: !!rule.value.enabled,
|
||||
threshold_minutes: Math.round(Number(rule.value.threshold_minutes) || 60),
|
||||
respect_business_hours: !!rule.value.respect_business_hours,
|
||||
business_hours_start: timeToDb(rule.value.business_hours_start),
|
||||
business_hours_end: timeToDb(rule.value.business_hours_end),
|
||||
business_days: [...(rule.value.business_days || [])].sort((a, b) => a - b),
|
||||
alert_scope: rule.value.alert_scope,
|
||||
notify_admin_on_breach: !!rule.value.notify_admin_on_breach
|
||||
};
|
||||
const { error } = await supabase
|
||||
.from('conversation_sla_rules')
|
||||
.upsert(payload, { onConflict: 'tenant_id' });
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2500 });
|
||||
loadBreaches();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBreaches() {
|
||||
if (!tenantId.value) return;
|
||||
breachesLoading.value = true;
|
||||
try {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_sla_breaches')
|
||||
.select('id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach, breached_at, resolved_at, notification_count')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.gte('breached_at', sevenDaysAgo)
|
||||
.order('breached_at', { ascending: false })
|
||||
.limit(30);
|
||||
if (error) throw error;
|
||||
breaches.value = data || [];
|
||||
} catch (e) {
|
||||
// falha silenciosa no histórico — não é bloqueante
|
||||
console.warn('[SLA] loadBreaches:', e?.message);
|
||||
} finally {
|
||||
breachesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function breachDurationMin(b) {
|
||||
const start = new Date(b.last_inbound_at).getTime();
|
||||
const end = new Date(b.resolved_at || Date.now()).getTime();
|
||||
return Math.max(0, Math.round((end - start) / 60000));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRule();
|
||||
await loadBreaches();
|
||||
});
|
||||
|
||||
watch(() => tenantStore.activeTenantId, async (id) => {
|
||||
if (id) {
|
||||
await loadRule();
|
||||
await loadBreaches();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-stopwatch" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="cfg-subheader__title">SLA de resposta</div>
|
||||
<div class="cfg-subheader__sub">Alerta quando conversa fica sem resposta além do tempo configurado.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width: 40px; height: 40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Card principal -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||
<!-- Toggle habilitar -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ToggleSwitch v-model="rule.enabled" inputId="sla-enabled" />
|
||||
<label for="sla-enabled" class="text-sm font-semibold cursor-pointer">Ativar alertas de SLA</label>
|
||||
</div>
|
||||
<Tag :value="rule.enabled ? 'Ativo' : 'Desativado'" :severity="rule.enabled ? 'success' : 'secondary'" class="text-[0.7rem]" />
|
||||
</div>
|
||||
|
||||
<!-- Threshold -->
|
||||
<div class="flex flex-col gap-1 pt-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
||||
Tempo máximo pra resposta (minutos)
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber v-model="rule.threshold_minutes" :min="1" :max="1440" showButtons buttonLayout="horizontal" :inputStyle="{ width: '5rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
|
||||
<span class="text-sm text-[var(--text-color-secondary)]">
|
||||
min
|
||||
<span v-if="rule.threshold_minutes >= 60" class="opacity-70">
|
||||
(≈ {{ Math.floor(rule.threshold_minutes / 60) }}h{{ rule.threshold_minutes % 60 ? ` ${rule.threshold_minutes % 60}min` : '' }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-[var(--text-color-secondary)]">Entre 1 min e 1440 min (24h).</small>
|
||||
</div>
|
||||
|
||||
<!-- Business hours -->
|
||||
<div class="border-t border-[var(--surface-border)] pt-3 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ToggleSwitch v-model="rule.respect_business_hours" inputId="sla-bh" />
|
||||
<label for="sla-bh" class="text-sm font-semibold cursor-pointer">Pausar fora do horário comercial</label>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] -mt-2 pl-[3.25rem]">
|
||||
Mensagens fora do horário só começam a contar no próximo dia útil.
|
||||
</div>
|
||||
|
||||
<div v-if="rule.respect_business_hours" class="pl-4 border-l-4 border-amber-400/50 flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Início</label>
|
||||
<InputText v-model="rule.business_hours_start" placeholder="08:00" maxlength="5" class="!w-24 text-center font-mono" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Fim</label>
|
||||
<InputText v-model="rule.business_hours_end" placeholder="18:00" maxlength="5" class="!w-24 text-center font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Dias úteis</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="d in DIAS_ISO"
|
||||
:key="d.iso"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs font-semibold rounded-full border transition-all"
|
||||
:class="rule.business_days?.includes(d.iso)
|
||||
? 'bg-amber-500 text-white border-amber-500'
|
||||
: 'bg-transparent text-[var(--text-color-secondary)] border-[var(--surface-border)] hover:border-amber-400/60'"
|
||||
@click="toggleDay(d.iso)"
|
||||
>
|
||||
{{ d.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Escopo -->
|
||||
<div class="border-t border-[var(--surface-border)] pt-3 flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
||||
Aplicar a quais conversas
|
||||
</label>
|
||||
<Select v-model="rule.alert_scope" :options="SCOPE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
|
||||
<small class="text-[var(--text-color-secondary)]">
|
||||
<strong>Apenas atribuídas:</strong> o alerta vai pro terapeuta responsável.
|
||||
<strong>Todas:</strong> inclui conversas sem responsável — o alerta vai só pros admins da clínica (se CC ligado).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Notify admin -->
|
||||
<div class="flex items-center gap-3 border-t border-[var(--surface-border)] pt-3">
|
||||
<ToggleSwitch v-model="rule.notify_admin_on_breach" inputId="sla-admin" />
|
||||
<label for="sla-admin" class="text-sm cursor-pointer">
|
||||
<span class="font-semibold">Notificar também admins da clínica</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">clinic_admin e tenant_admin recebem cópia dos alertas</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="flex justify-end border-t border-[var(--surface-border)] pt-3">
|
||||
<Button label="Salvar configuração" icon="pi pi-save" :loading="saving" @click="saveRule" class="rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico de breaches -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Estouros nos últimos 7 dias</h3>
|
||||
<Tag :value="breaches.length" severity="secondary" class="text-[0.65rem]" />
|
||||
</div>
|
||||
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="breachesLoading" v-tooltip.bottom="'Recarregar'" @click="loadBreaches" />
|
||||
</div>
|
||||
|
||||
<div v-if="breachesLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
|
||||
Carregando…
|
||||
</div>
|
||||
<div v-else-if="!breaches.length" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
|
||||
Nenhum estouro registrado — todas as respostas dentro do prazo 👏
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 max-h-[320px] overflow-y-auto text-xs">
|
||||
<div v-for="b in breaches" :key="b.id"
|
||||
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
|
||||
<Tag :value="b.resolved_at ? 'Resolvido' : 'Aberto'" :severity="b.resolved_at ? 'success' : 'danger'" class="text-[0.62rem]" />
|
||||
<span class="truncate">
|
||||
<span class="font-mono text-[0.65rem] text-[var(--text-color-secondary)]">{{ b.thread_key }}</span>
|
||||
<span class="ml-2 text-[var(--text-color-secondary)]">limite {{ b.threshold_minutes_at_breach }}min</span>
|
||||
</span>
|
||||
<span class="text-[var(--text-color-secondary)] font-mono whitespace-nowrap">{{ formatDate(b.breached_at) }}</span>
|
||||
<span class="font-mono" :class="b.resolved_at ? 'text-green-600' : 'text-orange-600'">
|
||||
{{ breachDurationMin(b) }}min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user