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,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.';
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Card de analytics: Tempo médio de 1ª resposta WhatsApp
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Usado em ClinicDashboard (global do tenant) e TherapistDashboard
|
||||||
|
| (filtrado por user.id). 3 métricas + sparkline de evolução.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useFirstResponseAnalytics } from '@/composables/useFirstResponseAnalytics';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
therapistId: { type: String, default: null }, // se passar, filtra por user_id
|
||||||
|
showRanking: { type: Boolean, default: true } // desliga ranking (usado no therapist dashboard)
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useFirstResponseAnalytics();
|
||||||
|
|
||||||
|
const period = ref('30d');
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ label: '7 dias', value: '7d' },
|
||||||
|
{ label: '30 dias', value: '30d' },
|
||||||
|
{ label: '90 dias', value: '90d' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
await api.loadAll({ period: period.value, therapistId: props.therapistId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparkline: pega avg_seconds por bucket, normaliza pra 0..1
|
||||||
|
const sparkPoints = computed(() => {
|
||||||
|
const buckets = api.evolution.value || [];
|
||||||
|
if (buckets.length < 2) return '';
|
||||||
|
const values = buckets.map((b) => Number(b.avg_seconds) || 0);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const width = 100;
|
||||||
|
const height = 32;
|
||||||
|
return buckets
|
||||||
|
.map((b, i) => {
|
||||||
|
const x = (i / (buckets.length - 1)) * width;
|
||||||
|
const normalized = ((Number(b.avg_seconds) || 0) - min) / range;
|
||||||
|
// Inverte Y (SVG cresce pra baixo) e deixa margem
|
||||||
|
const y = height - (normalized * (height - 4)) - 2;
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparkTrend = computed(() => {
|
||||||
|
const buckets = api.evolution.value || [];
|
||||||
|
if (buckets.length < 2) return null;
|
||||||
|
const first = Number(buckets[0].avg_seconds) || 0;
|
||||||
|
const last = Number(buckets[buckets.length - 1].avg_seconds) || 0;
|
||||||
|
if (first === 0) return null;
|
||||||
|
const pct = ((last - first) / first) * 100;
|
||||||
|
return { pct: Math.round(pct), improving: last < first };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formata SLA %: se null/undefined → "—"
|
||||||
|
const slaComplianceLabel = computed(() => {
|
||||||
|
const rate = api.stats.value?.sla_compliance_rate;
|
||||||
|
if (rate === null || rate === undefined) return '—';
|
||||||
|
return `${rate}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const slaComplianceColor = computed(() => {
|
||||||
|
const rate = Number(api.stats.value?.sla_compliance_rate);
|
||||||
|
if (!rate && rate !== 0) return 'text-[var(--text-color-secondary)]';
|
||||||
|
if (rate >= 80) return 'text-emerald-600';
|
||||||
|
if (rate >= 50) return 'text-amber-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(reload);
|
||||||
|
watch(() => [period.value, props.therapistId], reload);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dash-card rounded-md overflow-hidden">
|
||||||
|
<div class="dash-card__head gap-2.5 p-3 flex items-center">
|
||||||
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<i class="pi pi-stopwatch text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="dash-card__title">Tempo de 1ª resposta</div>
|
||||||
|
<div class="dash-card__sub">{{ therapistId ? 'Suas respostas no WhatsApp' : 'Respostas da clínica no WhatsApp' }}</div>
|
||||||
|
</div>
|
||||||
|
<Select v-model="period" :options="PERIOD_OPTIONS" optionLabel="label" optionValue="value" class="!text-xs" :pt="{ root: { style: 'width: 7rem' } }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 flex flex-col gap-3">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="api.loading.value" class="flex justify-center py-6">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sem dados -->
|
||||||
|
<div v-else-if="!api.hasData.value" class="text-center py-6 text-sm text-[var(--text-color-secondary)]">
|
||||||
|
<i class="pi pi-inbox text-2xl opacity-40 block mb-2" />
|
||||||
|
Sem respostas registradas no período.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métricas -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-2.5 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Média</span>
|
||||||
|
<span class="text-lg font-bold">{{ api.formatSeconds(api.stats.value?.avg_seconds) }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">mediana {{ api.formatSeconds(api.stats.value?.median_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-2.5 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">SLA cumprido</span>
|
||||||
|
<span class="text-lg font-bold" :class="slaComplianceColor">{{ slaComplianceLabel }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">
|
||||||
|
<template v-if="api.stats.value?.sla_threshold_seconds">
|
||||||
|
≤ {{ Math.round(api.stats.value.sla_threshold_seconds / 60) }}min
|
||||||
|
</template>
|
||||||
|
<template v-else>SLA desativado</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-2.5 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Respostas</span>
|
||||||
|
<span class="text-lg font-bold">{{ api.stats.value?.runs_count }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">no período</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sparkline -->
|
||||||
|
<div v-if="sparkPoints" class="flex items-center gap-3 border-t border-[var(--surface-border)] pt-2">
|
||||||
|
<svg viewBox="0 0 100 32" preserveAspectRatio="none" class="h-8 flex-1">
|
||||||
|
<polyline
|
||||||
|
:points="sparkPoints"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
:class="sparkTrend?.improving ? 'text-emerald-500' : 'text-orange-500'"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div v-if="sparkTrend" class="flex flex-col items-end text-[0.7rem] leading-tight">
|
||||||
|
<span :class="sparkTrend.improving ? 'text-emerald-600' : 'text-orange-600'" class="font-semibold">
|
||||||
|
{{ sparkTrend.improving ? '↓' : '↑' }} {{ Math.abs(sparkTrend.pct) }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)]">
|
||||||
|
{{ sparkTrend.improving ? 'melhorando' : 'piorando' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ranking (só clínica) -->
|
||||||
|
<div v-if="showRanking && api.ranking.value.length > 0" class="border-t border-[var(--surface-border)] pt-2">
|
||||||
|
<div class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)] mb-1.5">Ranking terapeutas</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div v-for="(t, i) in api.ranking.value.slice(0, 5)" :key="t.therapist_id"
|
||||||
|
class="flex items-center justify-between text-xs py-1 px-1.5 rounded hover:bg-[var(--surface-hover)]">
|
||||||
|
<span class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<span class="font-mono text-[0.65rem] text-[var(--text-color-secondary)] w-4">{{ i + 1 }}.</span>
|
||||||
|
<span class="font-mono text-[0.68rem] truncate">{{ t.therapist_id.slice(0, 8) }}…</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2 text-[var(--text-color-secondary)]">
|
||||||
|
<span>{{ t.runs_count }}</span>
|
||||||
|
<span class="font-mono">{{ api.formatSeconds(t.avg_seconds) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import Menu from 'primevue/menu';
|
|||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { useClinicKPIs } from '@/composables/useClinicKPIs';
|
import { useClinicKPIs } from '@/composables/useClinicKPIs';
|
||||||
|
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
|
||||||
|
|
||||||
// Fase 3a — KPIs financeiros/operacionais da clínica
|
// Fase 3a — KPIs financeiros/operacionais da clínica
|
||||||
const kpis = useClinicKPIs();
|
const kpis = useClinicKPIs();
|
||||||
@@ -953,6 +954,9 @@ onMounted(async () => {
|
|||||||
<div class="dash-card__foot" @click="$router.push('/admin/agendamentos-recebidos')">Ver todas →</div>
|
<div class="dash-card__foot" @click="$router.push('/admin/agendamentos-recebidos')">Ver todas →</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics: tempo de 1ª resposta WhatsApp -->
|
||||||
|
<FirstResponseCard v-if="!loading" />
|
||||||
|
|
||||||
<!-- Cadastros externos -->
|
<!-- Cadastros externos -->
|
||||||
<div v-if="!loading" class="dash-card">
|
<div v-if="!loading" class="dash-card">
|
||||||
<div class="dash-card__head gap-2.5 p-2.5">
|
<div class="dash-card__head gap-2.5 p-2.5">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
|
|||||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
||||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
||||||
|
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
|
||||||
|
|
||||||
const dashHeroSentinelRef = ref(null);
|
const dashHeroSentinelRef = ref(null);
|
||||||
const heroStuck = ref(false);
|
const heroStuck = ref(false);
|
||||||
@@ -1082,6 +1083,11 @@ onMounted(async () => {
|
|||||||
<div class="dash-card__foot" @click="$router.push('/therapist/agendamentos-recebidos')">Ver todas →</div>
|
<div class="dash-card__foot" @click="$router.push('/therapist/agendamentos-recebidos')">Ver todas →</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics: tempo de 1ª resposta WhatsApp (minhas) -->
|
||||||
|
<div id="card-first-response" class="anim-child [--delay:40ms]">
|
||||||
|
<FirstResponseCard :therapistId="ownerId" :showRanking="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cadastros externos -->
|
<!-- Cadastros externos -->
|
||||||
<div id="card-cadastros" class="anim-child [--delay:80ms] dash-card">
|
<div id="card-cadastros" class="anim-child [--delay:80ms] dash-card">
|
||||||
<div class="dash-card__head gap-2.5 p-2.5">
|
<div class="dash-card__head gap-2.5 p-2.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user