|
|
@@ -0,0 +1,347 @@
|
|
|
+<script>
|
|
|
+ import { onMount } from 'svelte';
|
|
|
+ import {
|
|
|
+ HeartPulse,
|
|
|
+ ShieldCheck,
|
|
|
+ AlertCircle,
|
|
|
+ AlertTriangle,
|
|
|
+ ShieldAlert,
|
|
|
+ Target,
|
|
|
+ CalendarClock,
|
|
|
+ Search,
|
|
|
+ Check,
|
|
|
+ LoaderCircle
|
|
|
+ } from 'lucide-svelte';
|
|
|
+ import { api } from '$lib/core/api/client.js';
|
|
|
+ import { getConversationAnalysis } from '$lib/features/churn/api.js';
|
|
|
+
|
|
|
+ // ── Lista de conversas (GET /v1/interactions) ───────────────────────────────
|
|
|
+ let conversations = $state([]);
|
|
|
+ let listLoading = $state(true);
|
|
|
+ let listError = $state('');
|
|
|
+ let searchQuery = $state('');
|
|
|
+ let searchTimer;
|
|
|
+
|
|
|
+ // ── Conversa selecionada + análise (GET /v1/conversations/analysis) ─────────
|
|
|
+ let selectedId = $state(null);
|
|
|
+ let analysis = $state(null);
|
|
|
+ let analysisLoading = $state(false);
|
|
|
+ let analysisError = $state('');
|
|
|
+
|
|
|
+ const hasAnalysis = $derived(
|
|
|
+ analysis !== null &&
|
|
|
+ (analysis.relationalState !== '' ||
|
|
|
+ analysis.opportunityType !== '' ||
|
|
|
+ analysis.recommendedAction !== '')
|
|
|
+ );
|
|
|
+
|
|
|
+ // ── Mapeamentos de exibição ──────────────────────────────────────────────────
|
|
|
+ const RELATIONAL_STATE = {
|
|
|
+ healthy: {
|
|
|
+ label: 'Relacionamento Saudável',
|
|
|
+ icon: ShieldCheck,
|
|
|
+ badge:
|
|
|
+ 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-400/10 dark:text-emerald-400 dark:border-emerald-400/20',
|
|
|
+ dot: 'bg-emerald-500'
|
|
|
+ },
|
|
|
+ attention: {
|
|
|
+ label: 'Atenção Necessária',
|
|
|
+ icon: AlertCircle,
|
|
|
+ badge:
|
|
|
+ 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-400/10 dark:text-amber-400 dark:border-amber-400/20',
|
|
|
+ dot: 'bg-amber-500'
|
|
|
+ },
|
|
|
+ fragile: {
|
|
|
+ label: 'Relacionamento Fragilizado',
|
|
|
+ icon: AlertTriangle,
|
|
|
+ badge:
|
|
|
+ 'bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-400/10 dark:text-orange-400 dark:border-orange-400/20',
|
|
|
+ dot: 'bg-orange-500'
|
|
|
+ },
|
|
|
+ critical: {
|
|
|
+ label: 'Relacionamento Crítico',
|
|
|
+ icon: ShieldAlert,
|
|
|
+ badge:
|
|
|
+ 'bg-red-50 text-red-700 border-red-200 dark:bg-red-400/10 dark:text-red-400 dark:border-red-400/20',
|
|
|
+ dot: 'bg-red-500'
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const OPPORTUNITY_LABEL = {
|
|
|
+ purchase_interest: 'Interesse de Compra',
|
|
|
+ continuity_interest: 'Interesse em Continuidade',
|
|
|
+ renewal_interest: 'Interesse em Renovação',
|
|
|
+ info_request: 'Solicitação de Informações',
|
|
|
+ problem_resolved: 'Problema Resolvido',
|
|
|
+ recovery_opportunity: 'Oportunidade de Recuperação',
|
|
|
+ loyalty_opportunity: 'Oportunidade de Fidelização'
|
|
|
+ };
|
|
|
+
|
|
|
+ function stateConfig(state) {
|
|
|
+ return RELATIONAL_STATE[state] ?? null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function opportunityLabel(type) {
|
|
|
+ return OPPORTUNITY_LABEL[type] ?? (type || '—');
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatDeadline(iso) {
|
|
|
+ if (!iso) return '';
|
|
|
+ const date = new Date(iso);
|
|
|
+ if (Number.isNaN(date.getTime())) return '';
|
|
|
+ const formatted = new Intl.DateTimeFormat('pt-BR', {
|
|
|
+ day: '2-digit',
|
|
|
+ month: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit'
|
|
|
+ }).format(date);
|
|
|
+ return `até ${formatted}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── Carregamento ──────────────────────────────────────────────────────────────
|
|
|
+ async function loadConversations() {
|
|
|
+ listLoading = true;
|
|
|
+ listError = '';
|
|
|
+ try {
|
|
|
+ const data = await api.get('/v1/interactions', {
|
|
|
+ query: { page: 1, per_page: 50, search: searchQuery.trim(), filter: 'all' }
|
|
|
+ });
|
|
|
+ conversations = data.items ?? [];
|
|
|
+ } catch (err) {
|
|
|
+ listError = err?.message ?? 'Falha ao carregar as conversas.';
|
|
|
+ conversations = [];
|
|
|
+ } finally {
|
|
|
+ listLoading = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function onSearchInput() {
|
|
|
+ clearTimeout(searchTimer);
|
|
|
+ searchTimer = setTimeout(loadConversations, 400);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function selectConversation(conversation) {
|
|
|
+ selectedId = conversation.conversationId;
|
|
|
+ analysis = null;
|
|
|
+ analysisError = '';
|
|
|
+ analysisLoading = true;
|
|
|
+ try {
|
|
|
+ analysis = await getConversationAnalysis(conversation.conversationId);
|
|
|
+ } catch (err) {
|
|
|
+ analysisError = err?.message ?? 'Falha ao carregar a análise desta conversa.';
|
|
|
+ } finally {
|
|
|
+ analysisLoading = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const selectedConversation = $derived(
|
|
|
+ conversations.find((c) => c.conversationId === selectedId) ?? null
|
|
|
+ );
|
|
|
+
|
|
|
+ onMount(loadConversations);
|
|
|
+</script>
|
|
|
+
|
|
|
+<svelte:head>
|
|
|
+ <title>Análise de Churn - Nettown Analytics</title>
|
|
|
+</svelte:head>
|
|
|
+
|
|
|
+<div class="mx-auto max-w-[1600px] space-y-6">
|
|
|
+ <!-- Header -->
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-colors duration-200 md:p-6 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div
|
|
|
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-rose-500/15 text-rose-500"
|
|
|
+ >
|
|
|
+ <HeartPulse size={20} strokeWidth={2.5} />
|
|
|
+ </div>
|
|
|
+ <h1 class="text-xl font-bold text-slate-900 dark:text-white">Análise de Churn</h1>
|
|
|
+ </div>
|
|
|
+ <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
|
|
+ Estado relacional, oportunidades e próxima ação recomendada por conversa, gerados pela análise
|
|
|
+ de IA.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
|
|
|
+ <!-- Coluna esquerda: lista de conversas -->
|
|
|
+ <div
|
|
|
+ class="flex max-h-[70vh] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <div class="border-b border-slate-200 p-4 dark:border-slate-800">
|
|
|
+ <div class="relative">
|
|
|
+ <Search
|
|
|
+ size={16}
|
|
|
+ class="absolute top-1/2 left-3 -translate-y-1/2 text-slate-400 dark:text-slate-500"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ bind:value={searchQuery}
|
|
|
+ oninput={onSearchInput}
|
|
|
+ placeholder="Buscar cliente ou agente..."
|
|
|
+ class="w-full rounded-lg border border-slate-300 bg-white py-2 pr-4 pl-9 text-sm text-slate-900 placeholder-slate-400 shadow-sm transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:placeholder-slate-500"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ class="custom-scrollbar flex-1 divide-y divide-slate-100 overflow-y-auto dark:divide-slate-800/50"
|
|
|
+ >
|
|
|
+ {#if listLoading}
|
|
|
+ <div class="p-6 text-center text-sm text-slate-400">Carregando conversas…</div>
|
|
|
+ {:else if listError}
|
|
|
+ <div class="p-6 text-center text-sm text-red-500">{listError}</div>
|
|
|
+ {:else if conversations.length === 0}
|
|
|
+ <div class="p-6 text-center text-sm text-slate-400">Nenhuma conversa encontrada.</div>
|
|
|
+ {:else}
|
|
|
+ {#each conversations as conversation (conversation.conversationId)}
|
|
|
+ <button
|
|
|
+ onclick={() => selectConversation(conversation)}
|
|
|
+ class="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors {selectedId ===
|
|
|
+ conversation.conversationId
|
|
|
+ ? 'bg-indigo-50 dark:bg-indigo-500/10'
|
|
|
+ : 'hover:bg-slate-50 dark:hover:bg-slate-800/40'}"
|
|
|
+ >
|
|
|
+ <span class="text-sm font-semibold text-slate-900 dark:text-white">
|
|
|
+ {conversation.client}
|
|
|
+ </span>
|
|
|
+ <span class="text-xs text-slate-500 dark:text-slate-400">
|
|
|
+ {conversation.agent || 'Time Nettown'}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ {/each}
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Coluna direita: análise da conversa selecionada -->
|
|
|
+ <div class="min-w-0">
|
|
|
+ {#if selectedConversation === null}
|
|
|
+ <div
|
|
|
+ class="flex h-full min-h-[300px] items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-[#1e293b] dark:text-slate-400"
|
|
|
+ >
|
|
|
+ Selecione uma conversa à esquerda para ver a análise de risco.
|
|
|
+ </div>
|
|
|
+ {:else if analysisLoading}
|
|
|
+ <div
|
|
|
+ class="flex h-full min-h-[300px] items-center justify-center rounded-xl border border-slate-200 bg-white p-8 text-sm text-slate-400 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <LoaderCircle size={18} class="mr-2 animate-spin" /> Carregando análise…
|
|
|
+ </div>
|
|
|
+ {:else if analysisError}
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400"
|
|
|
+ >
|
|
|
+ {analysisError}
|
|
|
+ </div>
|
|
|
+ {:else if !hasAnalysis}
|
|
|
+ <div
|
|
|
+ class="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <AlertCircle size={28} class="text-slate-400" />
|
|
|
+ <p class="text-sm font-medium text-slate-600 dark:text-slate-300">
|
|
|
+ Análise ainda não disponível para esta conversa
|
|
|
+ </p>
|
|
|
+ <p class="text-xs text-slate-400">
|
|
|
+ A análise é gerada por um processo de IA e ainda não foi concluída.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ {:else}
|
|
|
+ {@const cfg = stateConfig(analysis.relationalState)}
|
|
|
+ <div class="space-y-6">
|
|
|
+ <!-- Card 1 — Estado Relacional -->
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-colors duration-200 md:p-6 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <h2
|
|
|
+ class="text-xs font-bold tracking-wider text-slate-500 uppercase dark:text-slate-400"
|
|
|
+ >
|
|
|
+ Estado Relacional
|
|
|
+ </h2>
|
|
|
+ <div class="mt-3">
|
|
|
+ {#if cfg}
|
|
|
+ {@const StateIcon = cfg.icon}
|
|
|
+ <span
|
|
|
+ class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-semibold {cfg.badge}"
|
|
|
+ >
|
|
|
+ <StateIcon size={16} />
|
|
|
+ {cfg.label}
|
|
|
+ </span>
|
|
|
+ {:else}
|
|
|
+ <span class="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
|
+ {analysis.relationalState || '—'}
|
|
|
+ </span>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {#if analysis.relationalStateSummary}
|
|
|
+ <p class="mt-4 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
|
|
+ {analysis.relationalStateSummary}
|
|
|
+ </p>
|
|
|
+ {/if}
|
|
|
+
|
|
|
+ {#if analysis.relationalSignals.length > 0}
|
|
|
+ <ul class="mt-4 space-y-2">
|
|
|
+ {#each analysis.relationalSignals as signal}
|
|
|
+ <li class="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-200">
|
|
|
+ <Check size={16} class="mt-0.5 shrink-0 text-emerald-500" />
|
|
|
+ <span>{signal}</span>
|
|
|
+ </li>
|
|
|
+ {/each}
|
|
|
+ </ul>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Card 2 — Oportunidade Identificada -->
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-colors duration-200 md:p-6 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <h2
|
|
|
+ class="text-xs font-bold tracking-wider text-slate-500 uppercase dark:text-slate-400"
|
|
|
+ >
|
|
|
+ Oportunidade Identificada
|
|
|
+ </h2>
|
|
|
+ <div class="mt-3 flex items-center gap-2">
|
|
|
+ <span
|
|
|
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-500"
|
|
|
+ >
|
|
|
+ <Target size={16} />
|
|
|
+ </span>
|
|
|
+ <span class="text-base font-semibold text-indigo-600 dark:text-indigo-400">
|
|
|
+ {opportunityLabel(analysis.opportunityType)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ {#if analysis.opportunitySummary}
|
|
|
+ <p class="mt-4 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
|
|
+ {analysis.opportunitySummary}
|
|
|
+ </p>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Card 3 — Próxima Ação Recomendada -->
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-colors duration-200 md:p-6 dark:border-slate-800 dark:bg-[#1e293b]"
|
|
|
+ >
|
|
|
+ <h2
|
|
|
+ class="text-xs font-bold tracking-wider text-slate-500 uppercase dark:text-slate-400"
|
|
|
+ >
|
|
|
+ Próxima Ação Recomendada
|
|
|
+ </h2>
|
|
|
+ <p class="mt-3 text-sm leading-relaxed text-slate-700 dark:text-slate-200">
|
|
|
+ {analysis.recommendedAction || '—'}
|
|
|
+ </p>
|
|
|
+ {#if formatDeadline(analysis.recommendedActionDeadline)}
|
|
|
+ <div
|
|
|
+ class="mt-4 inline-flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
|
|
+ >
|
|
|
+ <CalendarClock size={14} />
|
|
|
+ {formatDeadline(analysis.recommendedActionDeadline)}
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</div>
|