Analytics 7.1: tempo médio de 1ª resposta WhatsApp no dashboard
Card novo pra clínica e terapeuta com 3 métricas + sparkline: - Tempo médio (e mediana) de 1ª resposta no período - Taxa de SLA cumprido — % de respostas dentro do threshold configurado - Contagem total de respostas no período - Sparkline da evolução com indicador de tendência (melhorando/piorando) - Ranking top 5 terapeutas (só no ClinicDashboard) Filtro de período: 7/30/90 dias (muda granularidade do bucket: 1/7/15 dias pra sparkline com ~5-6 pontos). Banco (migration 20260423000006): - Helper interno _first_response_runs: identifica "runs" de inbound (sequências do paciente sem outbound entre) e calcula delta até a próxima outbound. Evita contar múltiplas mensagens repetidas do paciente. responder_id vem de conversation_assignments. - first_response_stats: agregados (count, avg, median, min, max, sla_compliance_rate baseado em conversation_sla_rules). - first_response_by_therapist: ranking com avg e count por assigned_to. - first_response_evolution: série temporal com bucket alinhado a p_from (p_from + bucket_index * N days). Parâmetro p_bucket_days deixa o frontend escolher granularidade por período. Todas SECURITY DEFINER + GRANT authenticated/service_role. Filtro opcional por therapist_id nas funções que aplicam. Frontend: - useFirstResponseAnalytics composable wraps as 3 RPCs com cache via Promise.all paralelo. Helper formatSeconds (Ns/Xmin/Xh). - FirstResponseCard.vue renderiza sparkline SVG nativo (sem lib extra), cor da taxa SLA por threshold (verde ≥80%, âmbar ≥50%, vermelho). - Integrado em ClinicDashboard (visão global) e TherapistDashboard (filtrado por ownerId, sem ranking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useFirstResponseAnalytics.js
|
||||
|
|
||||
| Wrapper das 3 RPCs de analytics de tempo de 1ª resposta:
|
||||
| - first_response_stats (agregados do período)
|
||||
| - first_response_by_therapist (ranking)
|
||||
| - first_response_evolution (série temporal)
|
||||
|
|
||||
| Filtros: period ('7d' | '30d' | '90d') + therapist_id opcional.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const PERIOD_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
|
||||
const BUCKET_BY_PERIOD = { '7d': 1, '30d': 7, '90d': 15 };
|
||||
|
||||
export function useFirstResponseAnalytics() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const stats = ref(null);
|
||||
const ranking = ref([]);
|
||||
const evolution = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function loadAll({ period = '30d', therapistId = null } = {}) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
|
||||
const days = PERIOD_DAYS[period] || 30;
|
||||
const bucketDays = BUCKET_BY_PERIOD[period] || 7;
|
||||
const to = new Date();
|
||||
const from = new Date(Date.now() - days * 24 * 3600 * 1000);
|
||||
const fromIso = from.toISOString();
|
||||
const toIso = to.toISOString();
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const [sRes, rRes, eRes] = await Promise.all([
|
||||
supabase.rpc('first_response_stats', {
|
||||
p_tenant_id: tenantId,
|
||||
p_from: fromIso,
|
||||
p_to: toIso,
|
||||
p_therapist_id: therapistId
|
||||
}),
|
||||
supabase.rpc('first_response_by_therapist', {
|
||||
p_tenant_id: tenantId,
|
||||
p_from: fromIso,
|
||||
p_to: toIso
|
||||
}),
|
||||
supabase.rpc('first_response_evolution', {
|
||||
p_tenant_id: tenantId,
|
||||
p_from: fromIso,
|
||||
p_to: toIso,
|
||||
p_bucket_days: bucketDays,
|
||||
p_therapist_id: therapistId
|
||||
})
|
||||
]);
|
||||
|
||||
if (sRes.error) throw sRes.error;
|
||||
if (rRes.error) throw rRes.error;
|
||||
if (eRes.error) throw eRes.error;
|
||||
|
||||
stats.value = Array.isArray(sRes.data) ? (sRes.data[0] || null) : sRes.data;
|
||||
ranking.value = rRes.data || [];
|
||||
evolution.value = eRes.data || [];
|
||||
} catch (e) {
|
||||
error.value = e;
|
||||
stats.value = null;
|
||||
ranking.value = [];
|
||||
evolution.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers de formatação
|
||||
function formatSeconds(s) {
|
||||
const n = Number(s) || 0;
|
||||
if (n < 60) return `${n}s`;
|
||||
if (n < 3600) return `${Math.floor(n / 60)}min`;
|
||||
const h = Math.floor(n / 3600);
|
||||
const m = Math.floor((n % 3600) / 60);
|
||||
return m > 0 ? `${h}h ${m}min` : `${h}h`;
|
||||
}
|
||||
|
||||
const hasData = computed(() => (stats.value?.runs_count || 0) > 0);
|
||||
|
||||
return {
|
||||
stats,
|
||||
ranking,
|
||||
evolution,
|
||||
loading,
|
||||
error,
|
||||
hasData,
|
||||
loadAll,
|
||||
formatSeconds
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user