gdias 1 月之前
父节点
当前提交
d747ac1f02

+ 129 - 0
src/lib/features/sentiment/data/sentiment-dashboard.mock.js

@@ -0,0 +1,129 @@
+export const sentimentDashboardMockSource = {
+	summary: {
+		atRiskClients: { value: 23, image: '/images/sentiment/risk.svg' },
+		opportunities: { value: 41, image: '/images/sentiment/opportunity.svg' },
+		recentInteractions: { value: 186, image: '/images/sentiment/interactions.svg' },
+		netTrend: { value: '+12%', image: '/images/sentiment/trend.svg' }
+	},
+	alerts: [
+		{
+			id: 'alert-1',
+			clientName: 'Grupo Horizonte',
+			title: 'Grupo Horizonte com alta chance de churn',
+			description: 'Queda de engajamento nas ultimas 2 semanas.',
+			priority: 'high',
+			category: 'churn_risk',
+			avatar: '/images/sentiment/client-1.svg'
+		},
+		{
+			id: 'alert-2',
+			clientName: 'Comercial Atlas',
+			title: 'Comercial Atlas com frustracao recente',
+			description: '3 interacoes negativas seguidas nas ultimas 24h.',
+			priority: 'medium',
+			category: 'frustration',
+			avatar: '/images/sentiment/client-2.svg'
+		},
+		{
+			id: 'alert-3',
+			clientName: 'Rede Sol',
+			title: 'Rede Sol com intencao de compra detectada',
+			description: 'Aumento de mensagens sobre fechamento e pagamento.',
+			priority: 'high',
+			category: 'buying_intent',
+			avatar: '/images/sentiment/client-3.svg'
+		},
+		{
+			id: 'alert-4',
+			clientName: 'Nexus Store',
+			title: 'Nexus Store com oportunidade ativa',
+			description: 'Interesse recorrente em plano premium.',
+			priority: 'low',
+			category: 'buying_intent',
+			avatar: '/images/sentiment/client-4.svg'
+		}
+	],
+	timeline: [
+		{ period: 'Sem 1', gains: 38, losses: 16 },
+		{ period: 'Sem 2', gains: 44, losses: 18 },
+		{ period: 'Sem 3', gains: 49, losses: 21 },
+		{ period: 'Sem 4', gains: 56, losses: 19 },
+		{ period: 'Sem 5', gains: 63, losses: 22 },
+		{ period: 'Sem 6', gains: 68, losses: 24 }
+	],
+	aspects: [
+		{
+			id: 'atendimento',
+			name: 'Atendimento',
+			positive: [
+				{ text: 'Fui respondida super rapido e com muita atencao.', client: 'Maria Silva' },
+				{ text: 'A vendedora entendeu exatamente o que eu precisava.', client: '5511988887777' },
+				{ text: 'Atendimento nota 10, resolveram meu problema na hora.', client: 'Joao Pedro' },
+				{ text: 'Amei a paciencia da atendente comigo.', client: '5541999991111' },
+				{ text: 'Muito educados e prestativos em todo momento.', client: 'Ana Julia' },
+				{ text: 'Explicaram tudo certinho sobre o produto.', client: 'Carlos Costa' },
+				{ text: 'Sempre compro aqui pelo otimo atendimento.', client: '5521977776666' },
+				{ text: 'Me ajudaram a escolher a melhor opcao para o meu caso.', client: 'Fernanda Lima' },
+				{ text: 'Atendimento humanizado de verdade.', client: 'Roberto Souza' },
+				{ text: 'Nao tive dor de cabeca nenhuma, excelente.', client: '5519966665555' },
+				{ text: 'Essa frase nao deve aparecer se limitar a 10.', client: 'Extra' }
+			],
+			neutral: [
+				{ text: 'O atendimento foi normal, sem problemas.', client: 'Marcos Paulo' },
+				{ text: 'Recebi as informacoes basicas que eu precisava.', client: '5551955554444' }
+			],
+			negative: [
+				{ text: 'Demoraram muito para me responder no WhatsApp.', client: 'Lucas Martins' },
+				{ text: 'Precisei repetir a mesma duvida varias vezes.', client: '5531944443333' }
+			]
+		},
+		{
+			id: 'produto',
+			name: 'Produto',
+			positive: [
+				{ text: 'A qualidade da peca surpreendeu, muito boa mesmo.', client: 'Renata Castro' },
+				{ text: 'O tecido e o acabamento sao excelentes.', client: '5581933332222' }
+			],
+			neutral: [
+				{ text: 'O produto e bom, dentro do esperado.', client: 'Julio Cesar' },
+				{ text: 'Achei as opcoes ok para o que eu procurava.', client: '5561922221111' }
+			],
+			negative: [
+				{ text: 'O tamanho ficou diferente do que eu esperava.', client: 'Patricia Alves' },
+				{ text: 'Nao tinha a cor que eu queria em estoque.', client: '5571911110000' }
+			]
+		},
+		{
+			id: 'entrega',
+			name: 'Entrega',
+			positive: [
+				{ text: 'Chegou antes do prazo e veio muito bem embalado.', client: 'Camila Rocha' },
+				{ text: 'O rastreio funcionou certinho do inicio ao fim.', client: '5591900009999' }
+			],
+			neutral: [
+				{ text: 'Recebi no prazo informado, tudo certo.', client: 'Rodrigo Freitas' },
+				{ text: 'A entrega aconteceu sem atrasos relevantes.', client: '5585988887777' }
+			],
+			negative: [
+				{ text: 'Meu pedido atrasou e nao tive atualizacao.', client: 'Amanda Nunes' },
+				{ text: 'A transportadora marcou entregue sem me avisar.', client: '5598977776666' }
+			]
+		},
+		{
+			id: 'monetario',
+			name: 'Monetario',
+			positive: [
+				{ text: 'O desconto ajudou muito na decisao de compra.', client: 'Diego Moraes' },
+				{ text: 'As condicoes de pagamento ficaram boas para mim.', client: '5511966665555' }
+			],
+			neutral: [
+				{ text: 'O preco esta na media do mercado.', client: 'Beatriz Santos' },
+				{ text: 'Valor justo para o tipo de produto.', client: '5521955554444' }
+			],
+			negative: [
+				{ text: 'O frete ficou caro para minha regiao.', client: 'Thiago Oliveira' },
+				{ text: 'O preco final ficou acima do que eu esperava.', client: '5531944443333' }
+			]
+		}
+	]
+};

+ 150 - 0
src/lib/features/sentiment/domain/sentiment-dashboard.service.js

@@ -0,0 +1,150 @@
+import { sentimentDashboardMockSource } from '../data/sentiment-dashboard.mock.js';
+
+const summaryCardsConfig = [
+	{ key: 'atRiskClients', label: 'Clientes em risco' },
+	{ key: 'opportunities', label: 'Oportunidades' },
+	{ key: 'recentInteractions', label: 'Interacoes recentes' },
+	{ key: 'netTrend', label: 'Tendencia liquida' }
+];
+
+const priorityLabel = {
+	high: 'Alta prioridade',
+	medium: 'Media prioridade',
+	low: 'Baixa prioridade'
+};
+
+export function getSentimentDashboardViewModel() {
+	return {
+		summaryCards: mapSummaryCards(sentimentDashboardMockSource.summary),
+		alerts: mapAlerts(sentimentDashboardMockSource.alerts),
+		timeline: sentimentDashboardMockSource.timeline,
+		aspects: mapAspects(sentimentDashboardMockSource.aspects)
+	};
+}
+
+export function getSummaryInsight(cardId, viewModel) {
+	const card = viewModel.summaryCards.find((item) => item.id === cardId);
+	if (!card) return createFallbackInsight();
+
+	const insightByCard = {
+		atRiskClients: {
+			title: 'Clientes em risco em destaque',
+			context: `${card.value} clientes apresentam sinais de queda de engajamento ou resposta tardia.`,
+			actions: [
+				'Priorizar contato em ate 24h para os casos com maior ticket.',
+				'Acionar playbook de retencao com oferta de recuperacao.',
+				'Distribuir os casos criticos para vendedores com melhor conversao.'
+			]
+		},
+		opportunities: {
+			title: 'Oportunidades comerciais ativas',
+			context: `${card.value} clientes estao com indicios de compra em aberto.`,
+			actions: [
+				'Executar abordagem consultiva nos clientes com intencao alta.',
+				'Enviar proposta com prazo curto para acelerar fechamento.',
+				'Marcar follow-up com dono da conta ainda hoje.'
+			]
+		},
+		recentInteractions: {
+			title: 'Volume recente de interacoes',
+			context: `${card.value} interacoes recentes exigem triagem por impacto no resultado.`,
+			actions: [
+				'Separar interacoes por risco, oportunidade e neutras.',
+				'Garantir SLA para mensagens sem retorno.',
+				'Acompanhar taxa de resolucao no primeiro contato.'
+			]
+		},
+		netTrend: {
+			title: 'Tendencia liquida de resultado',
+			context: `Tendencia atual de ${card.value}, indicando saldo positivo entre ganhos e perdas.`,
+			actions: [
+				'Escalar abordagem que gerou maior crescimento nas ultimas semanas.',
+				'Manter monitoramento de contas em risco para evitar reversao.',
+				'Transformar casos de sucesso em rotina para todo o time.'
+			]
+		}
+	};
+
+	return insightByCard[cardId] ?? createFallbackInsight();
+}
+
+export function getAlertInsight(alertId, viewModel) {
+	const alert = viewModel.alerts.find((item) => item.id === alertId);
+	if (!alert) return createFallbackInsight();
+
+	const categoryGuidance = {
+		churn_risk: [
+			'Fazer contato humano com proposta de recuperacao em ate 24h.',
+			'Identificar causa raiz da perda de interesse no ultimo ciclo.',
+			'Negociar condicoes para reativar a conta com menor atrito.'
+		],
+		frustration: [
+			'Responder com empatia e reconhecer o problema sem delay.',
+			'Resolver o ponto de frustracao com responsavel e prazo claro.',
+			'Fazer retorno ativo confirmando que o problema foi resolvido.'
+		],
+		buying_intent: [
+			'Acionar vendedor responsavel para proposta objetiva imediata.',
+			'Remover bloqueios de compra (prazo, condicao, produto).',
+			'Registrar proximo passo com data de fechamento prevista.'
+		]
+	};
+
+	return {
+		title: alert.title,
+		context: alert.description,
+		actions: categoryGuidance[alert.category] ?? createFallbackInsight().actions
+	};
+}
+
+export function getTimelineInsight(period, viewModel) {
+	const timelinePoint = viewModel.timeline.find((item) => item.period === period);
+	if (!timelinePoint) return createFallbackInsight();
+
+	const balance = timelinePoint.gains - timelinePoint.losses;
+	const trendText =
+		balance >= 0
+			? `saldo positivo de ${balance} pontos entre ganhos e perdas`
+			: `saldo negativo de ${Math.abs(balance)} pontos entre ganhos e perdas`;
+
+	return {
+		title: `Leitura da ${timelinePoint.period}`,
+		context: `Na ${timelinePoint.period}, houve ${timelinePoint.gains} ganhos e ${timelinePoint.losses} perdas, com ${trendText}.`,
+		actions: [
+			'Replicar o comportamento das contas com ganho na semana.',
+			'Atuar nos principais motivos de perda identificados no periodo.',
+			'Definir meta da proxima semana com base no saldo atual.'
+		]
+	};
+}
+
+function mapSummaryCards(summary) {
+	return summaryCardsConfig.map((config) => ({
+		id: config.key,
+		label: config.label,
+		value: summary[config.key]?.value ?? summary[config.key],
+		image: summary[config.key]?.image ?? null
+	}));
+}
+
+function mapAlerts(alerts) {
+	return alerts.map((alert) => ({
+		...alert,
+		priorityLabel: priorityLabel[alert.priority] ?? priorityLabel.low
+	}));
+}
+
+function mapAspects(aspects) {
+	return aspects.map((aspect) => ({
+		...aspect,
+		volume: aspect.positive.length + aspect.neutral.length + aspect.negative.length
+	}));
+}
+
+function createFallbackInsight() {
+	return {
+		title: 'Detalhes da analise',
+		context: 'Selecione um item para visualizar diagnostico e recomendacoes.',
+		actions: ['Priorizar casos de alto impacto.', 'Executar proximo passo comercial.', 'Monitorar resultado diariamente.']
+	};
+}

+ 71 - 0
src/lib/features/sentiment/ui/AlertsList.svelte

@@ -0,0 +1,71 @@
+<script>
+	let { alerts = [], onAlertSelect = () => {}, selectedAlertId = null } = $props();
+
+	const priorityClass = {
+		high: 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400',
+		medium:
+			'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400',
+		low: 'border-slate-200 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300'
+	};
+
+	function handleAlertClick(alert) {
+		onAlertSelect(alert);
+	}
+
+	const categoryLabel = {
+		churn_risk: 'Risco de churn',
+		frustration: 'Frustração recente',
+		buying_intent: 'Oportunidade ativa'
+	};
+</script>
+
+<section class="h-[460px] flex flex-col rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e293b] p-5 shadow-sm transition-colors duration-200">
+	<div class="mb-5 flex items-center justify-between border-b border-slate-100 dark:border-slate-800/50 pb-3 shrink-0">
+		<h2 class="text-sm font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase">Alertas</h2>
+		<span class="text-xs font-semibold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded">{alerts.length} itens</span>
+	</div>
+
+	<div class="space-y-3 flex-1 overflow-y-auto custom-scrollbar pr-2">
+		{#each alerts as alert}
+			<button
+				type="button"
+				class={`w-full rounded-xl border px-4 py-4 text-left transition-all duration-200 ${
+					selectedAlertId === alert.id
+						? 'border-indigo-300 bg-indigo-50 ring-1 ring-indigo-200 dark:border-indigo-500/50 dark:bg-indigo-500/10 dark:ring-indigo-500/20'
+						: 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/50 dark:bg-slate-800/30 dark:hover:border-slate-600 dark:hover:bg-slate-800/60'
+				}`}
+				onclick={() => handleAlertClick(alert)}
+			>
+				<div class="mb-4 w-full flex flex-wrap items-center gap-2">
+					<span class="rounded border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-bold text-slate-700 shadow-sm dark:border-slate-700/80 dark:bg-slate-800 dark:text-slate-300 dark:shadow-none transition-colors">
+						{categoryLabel[alert.category] ?? 'Alerta'}
+					</span>
+					<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">Clique para ver ação recomendada</span>
+				</div>
+				
+				<div class="flex items-center justify-between gap-3 mb-3 w-full">
+					<div class="flex items-center gap-3 min-w-0">
+						<div class="h-10 w-10 shrink-0 flex items-center justify-center rounded-full border border-slate-200 bg-white font-bold text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 shadow-sm dark:shadow-none">
+							{alert.clientName.substring(0, 2).toUpperCase()}
+						</div>
+						<p class="truncate text-sm font-bold text-slate-900 dark:text-white transition-colors">
+							{alert.clientName}
+						</p>
+					</div>
+					<span
+						class={`shrink-0 rounded border px-2 py-1 text-[10px] font-bold whitespace-nowrap transition-colors ${priorityClass[alert.priority]}`}
+					>
+						{alert.priorityLabel}
+					</span>
+				</div>
+
+				<div class="w-full space-y-1.5 mt-1">
+					<p class="text-[13px] leading-relaxed font-semibold text-slate-700 dark:text-slate-300 transition-colors">{alert.title}</p>
+					{#if alert.description}
+						<p class="text-xs leading-relaxed font-medium text-slate-500 dark:text-slate-400 transition-colors">{alert.description}</p>
+					{/if}
+				</div>
+			</button>
+		{/each}
+	</div>
+</section>

+ 110 - 0
src/lib/features/sentiment/ui/AspectFeedbackPanel.svelte

@@ -0,0 +1,110 @@
+<script>
+	let { aspects = [] } = $props();
+
+	let selectedSentiment = $state('positive');
+	let selectedAspectId = $state(null);
+
+	const sentimentOptions = [
+		{ id: 'positive', label: 'Positivo' },
+		{ id: 'neutral', label: 'Neutro' },
+		{ id: 'negative', label: 'Negativo' }
+	];
+
+	const activeAspectId = $derived(selectedAspectId ?? aspects[0]?.id ?? null);
+	const selectedAspect = $derived(aspects.find((aspect) => aspect.id === activeAspectId) ?? null);
+	const selectedQuotes = $derived(selectedAspect ? selectedAspect[selectedSentiment] ?? [] : []);
+
+	function getSentimentCount(aspect, sentiment) {
+		return aspect[sentiment]?.length ?? 0;
+	}
+</script>
+
+<section class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e293b] p-5 shadow-sm transition-colors duration-200">
+	<div class="flex flex-wrap items-center justify-between gap-4 border-b border-slate-100 dark:border-slate-800/50 pb-4">
+		<div>
+			<h2 class="text-sm font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase">
+				Aspectos e Frases Reais
+			</h2>
+			<p class="mt-1 text-xs font-medium text-slate-500 dark:text-slate-400 transition-colors">
+				Veja o que usuários estão falando por categoria e sentimento.
+			</p>
+		</div>
+		<div class="flex rounded-lg border border-slate-200 dark:border-slate-700/80 bg-slate-50 dark:bg-slate-900 p-1 transition-colors">
+			{#each sentimentOptions as option}
+				<button
+					type="button"
+					class={`rounded-md px-4 py-1.5 text-xs font-bold transition-colors ${
+						selectedSentiment === option.id
+							? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
+							: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
+					}`}
+					onclick={() => (selectedSentiment = option.id)}
+				>
+					{option.label}
+				</button>
+			{/each}
+		</div>
+	</div>
+
+	<div class="mt-5 flex flex-col xl:flex-row items-stretch gap-5">
+		<div class="space-y-3 w-full xl:w-[40%] shrink-0">
+			{#each aspects as aspect}
+				<button
+					type="button"
+					class={`w-full rounded-xl border px-4 py-3 text-left transition-all duration-200 ${
+						activeAspectId === aspect.id
+							? 'border-indigo-300 bg-indigo-50 ring-1 ring-indigo-200 dark:border-indigo-500/50 dark:bg-indigo-500/10 dark:ring-indigo-500/20'
+							: 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/50 dark:bg-slate-800/30 dark:hover:border-slate-600 dark:hover:bg-slate-800/60'
+					}`}
+					onclick={() => (selectedAspectId = aspect.id)}
+				>
+					<div class="flex items-center justify-between gap-2">
+						<p class="text-sm font-bold text-slate-900 dark:text-white transition-colors">{aspect.name}</p>
+						<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 transition-colors">{aspect.volume} frases</p>
+					</div>
+					<div class="mt-3 flex items-center gap-2 text-xs font-bold">
+						<span class="rounded bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 transition-colors">
+							+ {getSentimentCount(aspect, 'positive')}
+						</span>
+						<span class="rounded bg-slate-200 px-2 py-0.5 text-slate-700 dark:bg-slate-700 dark:text-slate-300 transition-colors">
+							= {getSentimentCount(aspect, 'neutral')}
+						</span>
+						<span class="rounded bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-500/20 dark:text-red-400 transition-colors">
+							- {getSentimentCount(aspect, 'negative')}
+						</span>
+					</div>
+				</button>
+			{/each}
+		</div>
+
+		<div class="w-full xl:w-[60%] relative min-h-[350px] xl:min-h-0">
+			<div class="rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-[#1e293b]/50 p-4 transition-colors flex flex-col h-full xl:absolute xl:inset-0">
+				{#if selectedAspect}
+					<div class="mb-4 flex items-center justify-between border-b border-slate-200 dark:border-slate-700/50 pb-3 shrink-0">
+						<h3 class="text-sm font-bold text-slate-900 dark:text-white transition-colors">{selectedAspect.name}</h3>
+						<span class="text-xs font-semibold text-slate-500 dark:text-slate-400 transition-colors">{Math.min(selectedQuotes.length, 10)} frases exibidas</span>
+					</div>
+
+					<div class="space-y-3 overflow-y-auto custom-scrollbar pr-2 flex-1">
+					{#if selectedQuotes.length === 0}
+						<p class="rounded-lg border border-slate-200 dark:border-slate-700/50 bg-white dark:bg-slate-800 px-4 py-3 text-sm font-medium text-slate-500 dark:text-slate-400 shadow-sm dark:shadow-none transition-colors">
+							Nenhuma frase encontrada nesse sentimento.
+						</p>
+					{:else}
+						{#each selectedQuotes.slice(0, 10) as quote}
+							<div class="rounded-lg border border-slate-200 dark:border-slate-700/50 bg-white dark:bg-slate-800 px-4 py-3 shadow-sm dark:shadow-none transition-colors">
+								<p class="text-sm font-medium text-slate-700 dark:text-slate-200 transition-colors">
+									"{quote.text}"
+								</p>
+								<span class="mt-2 block text-xs font-bold text-slate-400 dark:text-slate-500 transition-colors">
+									- {quote.client}
+								</span>
+							</div>
+						{/each}
+					{/if}
+				</div>
+			{/if}
+			</div>
+		</div>
+	</div>
+</section>

+ 95 - 0
src/lib/features/sentiment/ui/GainLossChart.svelte

@@ -0,0 +1,95 @@
+<script>
+	let { data = [], onPointSelect = () => {}, selectedPeriod = null } = $props();
+
+	const chartWidth = 760;
+	const chartHeight = 320;
+	const padding = { top: 30, right: 20, bottom: 50, left: 50 };
+
+	const innerWidth = chartWidth - padding.left - padding.right;
+	const innerHeight = chartHeight - padding.top - padding.bottom;
+
+	const maxValue = $derived(Math.max(1, ...data.map((item) => Math.max(item.gains, item.losses))));
+	const points = $derived(
+		data.map((item, index) => {
+			const xStep = data.length > 1 ? innerWidth / (data.length - 1) : 0;
+			const x = padding.left + index * xStep;
+			const gainsY = padding.top + (1 - item.gains / maxValue) * innerHeight;
+			const lossesY = padding.top + (1 - item.losses / maxValue) * innerHeight;
+			return { ...item, x, gainsY, lossesY };
+		})
+	);
+
+	const gainsPath = $derived(points.map((point) => `${point.x},${point.gainsY}`).join(' '));
+	const lossesPath = $derived(points.map((point) => `${point.x},${point.lossesY}`).join(' '));
+	const yAxisTicks = $derived([0, 0.25, 0.5, 0.75, 1].map((tick) => Math.round(maxValue * tick)));
+</script>
+
+<section class="h-[460px] flex flex-col rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e293b] p-5 shadow-sm transition-colors duration-200">
+	<div class="mb-6 flex items-center justify-between border-b border-slate-100 dark:border-slate-800/50 pb-3 shrink-0">
+		<h2 class="text-sm font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase">
+			Opinião do Público
+		</h2>
+		<div class="flex items-center gap-4 text-xs font-bold">
+			<span class="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
+				<i class="h-2.5 w-2.5 rounded-full bg-emerald-500"></i>Positivo
+			</span>
+			<span class="flex items-center gap-1.5 text-red-600 dark:text-red-400">
+				<i class="h-2.5 w-2.5 rounded-full bg-red-500"></i>Negativo
+			</span>
+		</div>
+	</div>
+
+	<div class="flex-1 w-full flex items-center justify-center pt-2">
+		<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} class="h-full w-full overflow-visible">
+			<!-- Background Grid -->
+			{#each yAxisTicks as tick}
+				{@const y = padding.top + (1 - tick / maxValue) * innerHeight}
+				<line
+					x1={padding.left}
+					y1={y}
+					x2={chartWidth - padding.right}
+					y2={y}
+					class="stroke-slate-200 dark:stroke-slate-700/80"
+					stroke-dasharray="4 4"
+				/>
+				<text x={padding.left - 12} y={y + 4} text-anchor="end" class="fill-slate-500 dark:fill-slate-400 text-xs font-medium">
+					{tick}
+				</text>
+			{/each}
+
+			<!-- Lines -->
+			<polyline fill="none" points={gainsPath} stroke="#10b981" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="drop-shadow-sm" />
+			<polyline fill="none" points={lossesPath} stroke="#ef4444" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="drop-shadow-sm" />
+
+			<!-- Points -->
+			{#each points as point}
+				<g
+					role="button"
+					tabindex="0"
+					aria-label={`Selecionar periodo ${point.period}`}
+					onclick={() => onPointSelect(point)}
+					onkeydown={(event) => (event.key === 'Enter' || event.key === ' ') && onPointSelect(point)}
+					class="cursor-pointer group outline-none"
+				>
+					<!-- Gain Point -->
+					<circle
+						cx={point.x}
+						cy={point.gainsY}
+						r={selectedPeriod === point.period ? 6 : 4}
+						class="fill-emerald-500 stroke-white dark:stroke-[#1e293b] stroke-2 transition-all duration-200 group-hover:r-6"
+					/>
+					<!-- Loss Point -->
+					<circle
+						cx={point.x}
+						cy={point.lossesY}
+						r={selectedPeriod === point.period ? 6 : 4}
+						class="fill-red-500 stroke-white dark:stroke-[#1e293b] stroke-2 transition-all duration-200 group-hover:r-6"
+					/>
+				</g>
+				<text x={point.x} y={chartHeight - 10} text-anchor="middle" class="fill-slate-500 dark:fill-slate-400 text-xs font-semibold">
+					{point.period}
+				</text>
+			{/each}
+		</svg>
+	</div>
+</section>

+ 35 - 0
src/lib/features/sentiment/ui/SummaryCards.svelte

@@ -0,0 +1,35 @@
+<script>
+	let { cards = [], onCardSelect = () => {}, selectedCardId = null } = $props();
+</script>
+
+<section class="space-y-3 transition-colors duration-200">
+	<h2 class="text-sm font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase">
+		Resumo Rápido
+	</h2>
+
+	<div class="grid grid-cols-2 gap-4 xl:grid-cols-4">
+		{#each cards as card}
+			<button
+				type="button"
+				class={`w-full rounded-xl border p-4 text-left shadow-sm transition-all duration-200 ${
+					selectedCardId === card.id
+						? 'border-indigo-300 bg-indigo-50 dark:border-indigo-500/50 dark:bg-indigo-500/10 ring-1 ring-indigo-200 dark:ring-indigo-500/20'
+						: 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-[#1e293b] dark:hover:border-slate-700 dark:hover:bg-slate-800/80'
+				}`}
+				onclick={() => onCardSelect(card)}
+			>
+				<div class="flex items-start justify-between gap-3">
+					<div>
+						<p class="text-3xl leading-none font-extrabold text-slate-900 dark:text-white transition-colors">{card.value}</p>
+						<p class="mt-2 text-xs font-semibold text-slate-500 dark:text-slate-400 transition-colors">{card.label}</p>
+					</div>
+					{#if card.image}
+						<div class="h-10 w-10 shrink-0 rounded-lg border border-slate-200 dark:border-slate-700/50 bg-slate-50 dark:bg-slate-900/50 p-2 flex items-center justify-center transition-colors">
+							<img src={card.image} alt={card.label} class="h-full w-full object-contain opacity-80 dark:opacity-100" />
+						</div>
+					{/if}
+				</div>
+			</button>
+		{/each}
+	</div>
+</section>

+ 1 - 1
src/routes/(app)/dashboard/+page.svelte

@@ -79,7 +79,7 @@
 			border: 'border-purple-200 dark:border-purple-400/20'
 		},
 		{
-			title: 'Conversas Ativas',
+			title: 'Total de conversas',
 			value: mockKpis.whatsappMessages.current,
 			// subvalue: `${mockKpis.whatsappMessages.total} total geral`,
 			icon: MessageSquare,

+ 106 - 0
src/routes/(app)/dashboard/analytics/+page.svelte

@@ -0,0 +1,106 @@
+<script>
+	import SummaryCards from '$lib/features/sentiment/ui/SummaryCards.svelte';
+	import AlertsList from '$lib/features/sentiment/ui/AlertsList.svelte';
+	import GainLossChart from '$lib/features/sentiment/ui/GainLossChart.svelte';
+	import AspectFeedbackPanel from '$lib/features/sentiment/ui/AspectFeedbackPanel.svelte';
+	import {
+		getSentimentDashboardViewModel,
+		getSummaryInsight,
+		getAlertInsight,
+		getTimelineInsight
+	} from '$lib/features/sentiment/domain/sentiment-dashboard.service.js';
+
+	const dashboardData = getSentimentDashboardViewModel();
+	const defaultInsight = {
+		title: 'Painel de decisao',
+		context: 'Clique em um card, alerta ou ponto do grafico para entender o que esta acontecendo e qual acao executar.',
+		actions: [
+			'Atue primeiro em risco alto e alto impacto.',
+			'Converta oportunidades com follow-up rapido.',
+			'Monitore tendencia de ganhos e perdas semanalmente.'
+		]
+	};
+
+	let selectedCardId = $state(null);
+	let selectedAlertId = $state(null);
+	let selectedPeriod = $state(null);
+	let selectedInsight = $state(defaultInsight);
+
+	function handleCardSelect(card) {
+		selectedCardId = card.id;
+		selectedAlertId = null;
+		selectedPeriod = null;
+		selectedInsight = getSummaryInsight(card.id, dashboardData);
+	}
+
+	function handleAlertSelect(alert) {
+		selectedCardId = null;
+		selectedAlertId = alert.id;
+		selectedPeriod = null;
+		selectedInsight = getAlertInsight(alert.id, dashboardData);
+	}
+
+	function handleTimelinePointSelect(point) {
+		selectedCardId = null;
+		selectedAlertId = null;
+		selectedPeriod = point.period;
+		selectedInsight = getTimelineInsight(point.period, dashboardData);
+	}
+</script>
+
+<svelte:head>
+	<title>Análise de Sentimento - Nettown Analytics</title>
+</svelte:head>
+
+<div class="mx-auto max-w-[1600px] space-y-6 transition-colors duration-200">
+	<SummaryCards
+		cards={dashboardData.summaryCards}
+		onCardSelect={handleCardSelect}
+		selectedCardId={selectedCardId}
+	/>
+
+	<section class="grid grid-cols-1 gap-5 2xl:grid-cols-8">
+		<div class="2xl:col-span-2 flex flex-col">
+			<AlertsList
+				alerts={dashboardData.alerts}
+				onAlertSelect={handleAlertSelect}
+				selectedAlertId={selectedAlertId}
+			/>
+		</div>
+		<div class="2xl:col-span-4 flex flex-col">
+			<GainLossChart
+				data={dashboardData.timeline}
+				onPointSelect={handleTimelinePointSelect}
+				selectedPeriod={selectedPeriod}
+			/>
+		</div>
+		<div class="2xl:col-span-2 flex flex-col">
+			<section
+				class="h-[460px] flex flex-col rounded-xl border border-slate-200 dark:border-slate-700/50 bg-white dark:bg-[#1e293b] p-5 shadow-sm transition-colors duration-200"
+			>
+				<h2 class="text-[11px] font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase shrink-0">
+					O QUE ESTA ACONTECENDO
+				</h2>
+				<div class="mt-4 flex-1 overflow-y-auto custom-scrollbar pr-2">
+					<div class="pb-5 border-b border-slate-100 dark:border-slate-700/50">
+						<h3 class="text-base font-bold text-slate-900 dark:text-white">{selectedInsight.title}</h3>
+						<p class="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300">{selectedInsight.context}</p>
+					</div>
+
+					<h4 class="mt-5 text-[11px] font-bold tracking-wider text-slate-500 dark:text-slate-400 uppercase">
+						O QUE FAZER AGORA
+					</h4>
+					<ul class="mt-3 space-y-2.5">
+						{#each selectedInsight.actions as action}
+							<li class="rounded-lg border border-slate-200 dark:border-slate-700/50 bg-white dark:bg-slate-800/30 px-3.5 py-3 text-xs font-medium text-slate-700 dark:text-slate-200 shadow-sm dark:shadow-none transition-colors">
+								{action}
+							</li>
+						{/each}
+					</ul>
+				</div>
+			</section>
+		</div>
+	</section>
+
+	<AspectFeedbackPanel aspects={dashboardData.aspects} />
+</div>

+ 4 - 0
static/images/sentiment/client-1.svg

@@ -0,0 +1,4 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="24" cy="24" r="24" fill="#FCA5A5"/>
+  <text x="24" y="29" text-anchor="middle" font-size="14" font-family="Arial, sans-serif" fill="#7F1D1D" font-weight="700">GH</text>
+</svg>

+ 4 - 0
static/images/sentiment/client-2.svg

@@ -0,0 +1,4 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="24" cy="24" r="24" fill="#FCD34D"/>
+  <text x="24" y="29" text-anchor="middle" font-size="14" font-family="Arial, sans-serif" fill="#78350F" font-weight="700">CA</text>
+</svg>

+ 4 - 0
static/images/sentiment/client-3.svg

@@ -0,0 +1,4 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="24" cy="24" r="24" fill="#86EFAC"/>
+  <text x="24" y="29" text-anchor="middle" font-size="14" font-family="Arial, sans-serif" fill="#14532D" font-weight="700">RS</text>
+</svg>

+ 4 - 0
static/images/sentiment/client-4.svg

@@ -0,0 +1,4 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="24" cy="24" r="24" fill="#93C5FD"/>
+  <text x="24" y="29" text-anchor="middle" font-size="14" font-family="Arial, sans-serif" fill="#1E3A8A" font-weight="700">NS</text>
+</svg>

+ 8 - 0
static/images/sentiment/interactions.svg

@@ -0,0 +1,8 @@
+<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect width="64" height="64" rx="16" fill="#EFF6FF"/>
+  <rect x="14" y="18" width="36" height="24" rx="8" fill="#60A5FA"/>
+  <circle cx="24" cy="30" r="2.5" fill="white"/>
+  <circle cx="32" cy="30" r="2.5" fill="white"/>
+  <circle cx="40" cy="30" r="2.5" fill="white"/>
+  <path d="M28 42L24 48L36 42H28Z" fill="#60A5FA"/>
+</svg>

+ 5 - 0
static/images/sentiment/opportunity.svg

@@ -0,0 +1,5 @@
+<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect width="64" height="64" rx="16" fill="#ECFDF5"/>
+  <circle cx="32" cy="32" r="14" fill="#34D399"/>
+  <path d="M25 32L30 37L39 27" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 6 - 0
static/images/sentiment/risk.svg

@@ -0,0 +1,6 @@
+<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect width="64" height="64" rx="16" fill="#FEF2F2"/>
+  <path d="M32 16L46 41H18L32 16Z" fill="#F87171"/>
+  <rect x="30" y="25" width="4" height="10" rx="2" fill="white"/>
+  <rect x="30" y="37" width="4" height="4" rx="2" fill="white"/>
+</svg>

+ 5 - 0
static/images/sentiment/trend.svg

@@ -0,0 +1,5 @@
+<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <rect width="64" height="64" rx="16" fill="#EEF2FF"/>
+  <path d="M16 42L27 31L35 37L48 24" stroke="#6366F1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
+  <path d="M40 24H48V32" stroke="#6366F1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>