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 @@
+
+
+
+
+