gdias преди 3 дни
родител
ревизия
56ac36056b
променени са 3 файла, в които са добавени 365 реда и са изтрити 1 реда
  1. 15 0
      src/lib/features/churn/api.js
  2. 3 1
      src/routes/(app)/+layout.svelte
  3. 347 0
      src/routes/(app)/dashboard/churn/+page.svelte

+ 15 - 0
src/lib/features/churn/api.js

@@ -0,0 +1,15 @@
+import { api } from '$lib/core/api/client.js';
+
+/**
+ * Análise de Risco/Churn de uma conversa (estado relacional, oportunidade
+ * identificada e próxima ação recomendada).
+ *
+ * Os valores são gerados por um processo de IA/ciência de dados externo;
+ * aqui apenas lemos o que está persistido. A ausência de análise é um estado
+ * válido — o backend responde 200 com os campos vazios/null.
+ */
+export async function getConversationAnalysis(conversationId) {
+	return api.get('/v1/conversations/analysis', {
+		query: { conversation_id: conversationId }
+	});
+}

+ 3 - 1
src/routes/(app)/+layout.svelte

@@ -20,7 +20,8 @@
 		CreditCard,
 		HelpCircle,
 		PieChart,
-		Users
+		Users,
+		HeartPulse
 	} from 'lucide-svelte';
 	import { goto } from '$app/navigation';
 	import logoWhite from '$lib/assets/images/nettown_white_logo.svg';
@@ -62,6 +63,7 @@
 		{ name: 'Dashboard Executivo', href: '/dashboard/executive', icon: PieChart, adminOnly: true },
 		{ name: 'Visão Geral', href: '/dashboard', icon: LayoutDashboard },
 		{ name: 'Interações', href: '/dashboard/interactions', icon: MessageSquare },
+		{ name: 'Análise de Churn', href: '/dashboard/churn', icon: HeartPulse },
 		{ name: 'Análise de Sentimento', href: '/dashboard/analytics', icon: BarChart2 },
 		{ name: 'Personas', href: '/dashboard/personas', icon: UserRound },
 		{ name: 'Evolução', href: '/dashboard/evolucao', icon: TrendingUp },

+ 347 - 0
src/routes/(app)/dashboard/churn/+page.svelte

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