feat: Implementa funil de CRM com geração de dados e visualização de timeline na interface.

This commit is contained in:
Rodrigo Borba 2026-01-04 16:00:28 -03:00
parent c02c5c198e
commit 78872a003a
8 changed files with 307 additions and 6 deletions

View File

@ -166,12 +166,10 @@ const toggleCrmInsights = () => {
/>
<Button
v-tooltip.top="t('CONVERSATION.CRM_INSIGHTS.TOGGLE')"
ghost
slate
sm
icon="i-lucide-brain"
class="bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400 border border-green-200 dark:border-green-800 !p-2 !h-10 !w-10 !text-xl"
:class="{
'bg-n-alpha-2': isCrmInsightsOpen,
'ring-2 ring-green-500 ring-offset-1': isCrmInsightsOpen,
}"
@click="toggleCrmInsights"
/>

View File

@ -14,6 +14,7 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import FunnelTimeline from './crm/FunnelTimeline.vue';
const props = defineProps({
currentChat: {
@ -154,6 +155,7 @@ const priceSensitivity = computed(
);
const confidence = computed(() => structuredData.value?.confidence);
const nba = computed(() => structuredData.value?.nba || {});
const funnelData = computed(() => structuredData.value?.funnel || {});
const generatedAt = computed(() => structuredData.value?.generated_at);
const agentTip = computed(() => structuredData.value?.agent_tip);
@ -537,6 +539,13 @@ watch(
</div>
<div class="grid gap-3">
<div
v-if="funnelData?.stage"
class="rounded-xl border border-n-weak overflow-hidden bg-n-alpha-1"
>
<FunnelTimeline :funnel-data="funnelData" />
</div>
<div v-if="intent" class="rounded-xl border border-n-weak p-3">
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.INTENT') }}

View File

@ -0,0 +1,160 @@
<script>
export default {
props: {
funnelData: {
type: Object,
default: () => ({}),
},
},
data() {
return {
steps: [
{ key: 'info' },
{ key: 'price' },
{ key: 'availability' },
{ key: 'confirmation' },
{ key: 'closed' },
],
};
},
computed: {
stage() {
return this.funnelData?.stage;
},
confidence() {
return this.funnelData?.confidence;
},
reason() {
return this.funnelData?.reason;
},
isClosedWon() {
return this.stage === 'closed_won';
},
isClosedLost() {
return this.stage === 'closed_lost';
},
currentStepKey() {
if (this.isClosedWon || this.isClosedLost) return 'closed';
return this.stage;
},
currentStepIndex() {
return this.steps.findIndex(s => s.key === this.currentStepKey);
},
currentStepLabel() {
if (this.isClosedWon) {
return this.$t('CONVERSATION.CRM_INSIGHTS.FUNNEL.STEPS.CLOSED_WON');
}
if (this.isClosedLost) {
return this.$t('CONVERSATION.CRM_INSIGHTS.FUNNEL.STEPS.CLOSED_LOST');
}
const stepKey = this.stage ? this.stage.toUpperCase() : '';
return stepKey
? this.$t(`CONVERSATION.CRM_INSIGHTS.FUNNEL.STEPS.${stepKey}`)
: this.stage;
},
statusColorClass() {
if (this.isClosedWon) return 'bg-green-500';
if (this.isClosedLost) return 'bg-slate-400';
return 'bg-woot-500';
},
},
methods: {
getStepLabel(stepKey) {
return this.$t(
`CONVERSATION.CRM_INSIGHTS.FUNNEL.STEPS.${stepKey.toUpperCase()}`
);
},
getStepClasses(stepKey, stepIndex) {
if (stepKey === this.currentStepKey) {
return this.getStepSpecificColor(stepKey);
}
if (this.currentStepIndex > stepIndex) {
return this.getStepSpecificColor(stepKey);
}
// Future Steps
return 'bg-slate-100 border-slate-200 dark:bg-slate-800 dark:border-slate-700';
},
getStepSpecificColor(stepKey) {
const colors = {
info: 'bg-blue-400 border-blue-400',
price: 'bg-indigo-400 border-indigo-400',
availability: 'bg-violet-400 border-violet-400',
confirmation: 'bg-purple-400 border-purple-400',
closed: this.isClosedWon
? 'bg-green-500 border-green-500'
: 'bg-slate-400 border-slate-400',
};
return colors[stepKey] || 'bg-woot-500 border-woot-500';
},
},
};
</script>
<template>
<div
class="px-4 py-3 border-b border-slate-100 dark:border-slate-800 bg-green-50/50 dark:bg-green-900/10"
>
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
>
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TITLE') }}
</span>
<span
v-if="confidence"
class="text-[10px] text-slate-400 bg-white/50 dark:bg-slate-800 px-1.5 py-0.5 rounded border border-slate-100 dark:border-slate-700"
>
{{
$t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TRUST', {
percentage: Math.round(confidence * 100),
})
}}
</span>
</div>
<!-- Timeline Steps -->
<div class="flex items-center justify-between relative mb-3 mt-1">
<!-- Connecting Line -->
<div
class="absolute top-1/2 left-0 w-full h-0.5 bg-slate-100 dark:bg-slate-800 -z-0"
/>
<!-- Steps -->
<div
v-for="(step, index) in steps"
:key="step.key"
v-tooltip.top="getStepLabel(step.key)"
class="relative z-10 flex flex-col items-center group cursor-help"
>
<div
class="w-3 h-3 rounded-full border-2 transition-all duration-300"
:class="getStepClasses(step.key, index)"
/>
</div>
</div>
<!-- Current Stage Info -->
<div
class="bg-slate-50 dark:bg-slate-800/50 rounded-md p-2.5 border border-slate-100 dark:border-slate-700/50"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="statusColorClass" />
<span class="text-xs font-bold text-slate-700 dark:text-slate-200">
{{ currentStepLabel }}
</span>
</div>
<p
v-if="reason"
class="text-xs text-slate-500 dark:text-slate-400 leading-relaxed"
>
{{ reason }}
</p>
<div class="mt-2 text-[10px] text-slate-400 italic">
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.DISCLAIMER') }}
</div>
</div>
</div>
</template>

View File

@ -160,6 +160,20 @@
"TITLE": "History",
"SHOW": "View history",
"HIDE": "Hide history"
},
"FUNNEL": {
"TITLE": "Sales Funnel",
"TRUST": "{percentage}% trust",
"DISCLAIMER": "* Stage automatically inferred by AI",
"STEPS": {
"INFO": "Info",
"PRICE": "Price",
"AVAILABILITY": "Availability",
"CONFIRMATION": "Confirmation",
"CLOSED": "Closed",
"CLOSED_WON": "Closed (Won)",
"CLOSED_LOST": "Closed (Lost)"
}
}
},
"RESOLVE_DROPDOWN": {

View File

@ -160,6 +160,20 @@
"TITLE": "Historico",
"SHOW": "Ver historico",
"HIDE": "Ocultar historico"
},
"FUNNEL": {
"TITLE": "Funil de Vendas",
"TRUST": "{percentage}% confiança",
"DISCLAIMER": "* Estágio inferido automaticamente pela IA",
"STEPS": {
"INFO": "Info",
"PRICE": "Preço",
"AVAILABILITY": "Disponibilidade",
"CONFIRMATION": "Confirmação",
"CLOSED": "Fim",
"CLOSED_WON": "Fechado (Ganho)",
"CLOSED_LOST": "Fechado (Perdido)"
}
}
},
"RESOLVE_DROPDOWN": {

View File

@ -60,10 +60,30 @@ module CrmInsights
"frictions": [],
"commercial_status": "",
"customer_potential": "",
"agent_tip": ""
"agent_tip": "",
"funnel": {
"stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost
"confidence": 0.0, // float 0-1
"reason": "justificativa curta",
"evidence_message_ids": [], // IDs das mensagens que justificam o estagio
"updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior
}
}
}
REGRAS FUNIL DE VENDAS (CRITICO):
1. Analise APENAS o historico fornecido abaixo para definir o estagio.
2. Estagios:
- info: pede informacoes gerais. (Confianca minima: qualquer)
- price: discute valores. (Confianca minima: 0.6)
- availability: pergunta sobre datas/vagas. (Confianca minima: 0.6)
- confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75)
- closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85)
- closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85)
3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio.
4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda.
5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info".
Contexto:
- Canal: #{channel_name}
- Conversa ID: #{@conversation.id}
@ -99,7 +119,7 @@ module CrmInsights
Duvidas recorrentes sobre formas de pagamento
Questionamentos frequentes sobre horario de check-in
Status comercial atual: 🟢 Alta chance de conversao
Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade)
Potencial do cliente:
Perfil recorrente

47
tmp_test_crm_funnel.rb Normal file
View File

@ -0,0 +1,47 @@
# Script to create a test conversation for CRM Funnel
# 1. Create Contact and Conversation
inbox = Inbox.first
account = inbox.account
puts "Using Inbox ##{inbox.id} (#{inbox.channel_type}) and Account ##{account.id}"
contact = Contact.create!(name: 'Cliente Teste Funil', phone_number: "+55119#{rand(10_000_000..99_999_999)}", account: account)
puts "Created Contact ##{contact.id}"
# Ensure ContactInbox exists
ci = ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |c|
c.source_id = contact.phone_number # valid for phone-based channels, might need adjustment for others
end
puts "Created/Found ContactInbox ##{ci.id}"
conv = Conversation.create!(contact: contact, inbox: inbox, contact_inbox: ci, account: account, status: :open)
puts "Created Conversation ##{conv.id}"
# 2. Add Messages (Booking Flow)
conv.messages.create!(content: 'Oi, gostaria de saber o preço da suíte com hidro para sábado.', message_type: :incoming, account: account,
inbox: inbox, sender: contact)
conv.messages.create!(content: 'Olá! A suíte está 400 reais.', message_type: :outgoing, account: account, inbox: inbox)
conv.messages.create!(content: 'Entendi. Tem disponibilidade?', message_type: :incoming, account: account, inbox: inbox, sender: contact)
# 3. Create CRM Insight with Funnel Data
data = {
'summary_text' => 'Cliente interessado em suíte com hidro para sábado. Perguntou preço e disponibilidade.',
'funnel' => {
'stage' => 'availability',
'confidence' => 0.85,
'reason' => 'Cliente perguntou explicitamente sobre disponibilidade após receber o preço.',
'evidence_message_ids' => conv.messages.pluck(:id),
'updated_at' => Time.current.iso8601
},
'intent' => 'Reserva',
'urgency' => 'high',
'commercial_status' => 'Interessado',
'preferences' => ['Suite Hidro'],
'contact_pattern' => { 'time_range' => 'Noite', 'days' => ['Sabado'] } # Adding more data for rich UI
}
conv.crm_insights.create!(account_id: 1, contact_id: conv.contact_id, structured_data: data, status: :success, generated_at: Time.current)
puts '========================================'
puts "SUCCESS! Open Conversation ##{conv.id} to test the Funnel UI."
puts '========================================'

View File

@ -0,0 +1,39 @@
# Script to update existing test conversations with Funnel Data
[7, 8, 9, 10, 11, 12, 13, 14, 15].each do |id|
conv = Conversation.find_by(id: id)
next unless conv
puts "Updating Conversation ##{id}..."
# Find or create insight
insight = conv.crm_insights.first
insight ||= conv.crm_insights.create!(
account_id: conv.account_id,
contact_id: conv.contact_id,
status: :success,
generated_at: Time.current,
structured_data: {}
)
data = insight.structured_data || {}
# Helper to mock messages if none exist (unlikely for these test ones but safe)
msg_ids = conv.messages.pluck(:id)
data['funnel'] = {
'stage' => 'availability',
'confidence' => 0.85,
'reason' => 'Cliente demostrou interesse e perguntou disponibilidade (Simulação).',
'evidence_message_ids' => msg_ids,
'updated_at' => Time.current.iso8601
}
# Ensure other requisite fields for the sidebar to look 'normal'
data['summary_text'] = 'Resumo automático para teste do funil (Dados Injetados Manualmente).'
data['intent'] = 'Reserva Rapida'
data['urgency'] = 'high'
data['commercial_status'] = 'Interessado'
insight.update!(structured_data: data)
puts "Conversation ##{id} UPDATED successfully."
end