diff --git a/database-novo/migrations/20260423000006_first_response_analytics.sql b/database-novo/migrations/20260423000006_first_response_analytics.sql new file mode 100644 index 0000000..50996e8 --- /dev/null +++ b/database-novo/migrations/20260423000006_first_response_analytics.sql @@ -0,0 +1,255 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: Analytics de tempo de 1ª resposta (Grupo 7.1) +-- ========================================================================== +-- Criado por: Leonardo Nohama +-- Data: 2026-04-23 · Sao Carlos/SP — Brasil +-- +-- 3 funcoes RPC pra popular card do dashboard: +-- 1. first_response_stats(tenant, from, to, therapist?) +-- Agregados do periodo: avg, median, count, sla_compliance_rate +-- 2. first_response_by_therapist(tenant, from, to) +-- Ranking: terapeuta → volume + tempo medio +-- 3. first_response_evolution(tenant, from, to, bucket_days) +-- Serie temporal (sparkline): buckets de N dias com avg e count +-- +-- Metodo: +-- Um "run" de inbound = sequencia de mensagens inbound consecutivas sem +-- nenhuma outbound entre elas. Isso evita contar multiplas mensagens +-- do paciente (o famoso "vi sua mensagem mas so respondi depois de 3 +-- disparos dele"). Pra cada run, o tempo conta a partir do PRIMEIRO +-- inbound ate a PROXIMA outbound. +-- +-- Thread key identifico como patient_id OU 'anon:'+from_number, consistente +-- com a view conversation_threads. +-- +-- SLA compliance: usa threshold da config conversation_sla_rules. +-- Se nao tem regra ou enabled=false, retorna sla_total=0. +-- ========================================================================== + +-- --------------------------------------------------------------------------- +-- fn helper interna: monta CTE dos "runs" de inbound com tempo de resposta +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public._first_response_runs( + p_tenant_id UUID, + p_from TIMESTAMPTZ, + p_to TIMESTAMPTZ +) +RETURNS TABLE ( + thread_key TEXT, + inbound_started_at TIMESTAMPTZ, + responded_at TIMESTAMPTZ, + response_seconds INT, + responder_id UUID +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + WITH msgs AS ( + SELECT + m.id, + m.tenant_id, + m.direction, + m.created_at, + m.patient_id, + m.from_number, + m.to_number, + -- mesma logica da view conversation_threads + COALESCE( + m.patient_id::text, + 'anon:' || COALESCE( + CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END, + 'unknown' + ) + ) AS tk + FROM public.conversation_messages m + WHERE m.tenant_id = p_tenant_id + AND m.direction IN ('inbound', 'outbound') + AND m.created_at >= p_from + AND m.created_at <= p_to + ), + with_prev AS ( + SELECT *, + LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction + FROM msgs + ), + run_starts AS ( + -- Primeira mensagem de cada "run inbound" + SELECT tk, tenant_id, created_at AS inbound_started_at + FROM with_prev + WHERE direction = 'inbound' + AND (prev_direction IS NULL OR prev_direction = 'outbound') + ) + SELECT + r.tk AS thread_key, + r.inbound_started_at, + o.created_at AS responded_at, + EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds, + -- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado) + a.assigned_to AS responder_id + FROM run_starts r + LEFT JOIN LATERAL ( + SELECT created_at + FROM public.conversation_messages m2 + WHERE m2.tenant_id = r.tenant_id + AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk + AND m2.direction = 'outbound' + AND m2.created_at > r.inbound_started_at + ORDER BY m2.created_at + LIMIT 1 + ) o ON true + LEFT JOIN public.conversation_assignments a + ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk + WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos +$$; + + +-- --------------------------------------------------------------------------- +-- first_response_stats: agregados do periodo +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.first_response_stats( + p_tenant_id UUID, + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now(), + p_therapist_id UUID DEFAULT NULL +) +RETURNS TABLE ( + runs_count INT, + avg_seconds INT, + median_seconds INT, + min_seconds INT, + max_seconds INT, + sla_threshold_seconds INT, + sla_compliant_count INT, + sla_compliance_rate NUMERIC +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_threshold_seconds INT; +BEGIN + -- Pega threshold do SLA (se habilitado) + SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END + INTO v_threshold_seconds + FROM public.conversation_sla_rules + WHERE tenant_id = p_tenant_id; + + RETURN QUERY + WITH runs AS ( + SELECT r.response_seconds, r.responder_id + FROM public._first_response_runs(p_tenant_id, p_from, p_to) r + WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id) + ) + SELECT + COUNT(*)::INT AS runs_count, + COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds, + COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds, + COALESCE(MIN(response_seconds), 0) AS min_seconds, + COALESCE(MAX(response_seconds), 0) AS max_seconds, + v_threshold_seconds AS sla_threshold_seconds, + COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count, + CASE + WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL + ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1) + END AS sla_compliance_rate + FROM runs; +END; +$$; + +REVOKE ALL ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) TO authenticated, service_role; + + +-- --------------------------------------------------------------------------- +-- first_response_by_therapist: ranking por terapeuta +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.first_response_by_therapist( + p_tenant_id UUID, + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now() +) +RETURNS TABLE ( + therapist_id UUID, + runs_count INT, + avg_seconds INT, + median_seconds INT +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + r.responder_id AS therapist_id, + COUNT(*)::INT AS runs_count, + AVG(r.response_seconds)::INT AS avg_seconds, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds + FROM public._first_response_runs(p_tenant_id, p_from, p_to) r + WHERE r.responder_id IS NOT NULL + GROUP BY r.responder_id + ORDER BY avg_seconds ASC; +$$; + +REVOKE ALL ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role; + + +-- --------------------------------------------------------------------------- +-- first_response_evolution: serie temporal (buckets de N dias) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.first_response_evolution( + p_tenant_id UUID, + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now(), + p_bucket_days INT DEFAULT 7, + p_therapist_id UUID DEFAULT NULL +) +RETURNS TABLE ( + bucket_start TIMESTAMPTZ, + runs_count INT, + avg_seconds INT +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + WITH runs AS ( + SELECT r.inbound_started_at, r.response_seconds + FROM public._first_response_runs(p_tenant_id, p_from, p_to) r + WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id) + ), + bucketed AS ( + SELECT + -- Janela alinhada a p_from: bucket_index * N dias + p_from + p_from + ( + FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT + * p_bucket_days * interval '1 day' + ) AS bucket_start, + response_seconds + FROM runs + ) + SELECT + bucket_start, + COUNT(*)::INT AS runs_count, + AVG(response_seconds)::INT AS avg_seconds + FROM bucketed + GROUP BY bucket_start + ORDER BY bucket_start; +$$; + +REVOKE ALL ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) TO authenticated, service_role; + +COMMENT ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) IS + 'Metricas agregadas de tempo de 1a resposta no periodo. Opcionalmente filtra por responder_id.'; + +COMMENT ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) IS + 'Ranking de tempo medio de 1a resposta por terapeuta atribuido.'; + +COMMENT ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) IS + 'Serie temporal em buckets de N dias pra sparkline.'; diff --git a/src/components/dashboard/FirstResponseCard.vue b/src/components/dashboard/FirstResponseCard.vue new file mode 100644 index 0000000..e27caad --- /dev/null +++ b/src/components/dashboard/FirstResponseCard.vue @@ -0,0 +1,175 @@ + + + + diff --git a/src/composables/useFirstResponseAnalytics.js b/src/composables/useFirstResponseAnalytics.js new file mode 100644 index 0000000..287174e --- /dev/null +++ b/src/composables/useFirstResponseAnalytics.js @@ -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 + }; +} diff --git a/src/views/pages/clinic/ClinicDashboard.vue b/src/views/pages/clinic/ClinicDashboard.vue index 6a63c01..63ad8a9 100644 --- a/src/views/pages/clinic/ClinicDashboard.vue +++ b/src/views/pages/clinic/ClinicDashboard.vue @@ -22,6 +22,7 @@ import Menu from 'primevue/menu'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; import { useClinicKPIs } from '@/composables/useClinicKPIs'; +import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue'; // Fase 3a — KPIs financeiros/operacionais da clínica const kpis = useClinicKPIs(); @@ -953,6 +954,9 @@ onMounted(async () => {
Ver todas →
+ + +
diff --git a/src/views/pages/therapist/TherapistDashboard.vue b/src/views/pages/therapist/TherapistDashboard.vue index 3db6791..788dc03 100644 --- a/src/views/pages/therapist/TherapistDashboard.vue +++ b/src/views/pages/therapist/TherapistDashboard.vue @@ -26,6 +26,7 @@ import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'; import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'; import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'; import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'; +import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue'; const dashHeroSentinelRef = ref(null); const heroStuck = ref(false); @@ -1082,6 +1083,11 @@ onMounted(async () => {
Ver todas →
+ +
+ +
+