feat: Implementa funil de CRM com geração de dados e visualização de timeline na interface.
This commit is contained in:
parent
c02c5c198e
commit
78872a003a
@ -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"
|
||||
/>
|
||||
|
||||
@ -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') }}
|
||||
|
||||
@ -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>
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -60,9 +60,29 @@ 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}
|
||||
@ -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
47
tmp_test_crm_funnel.rb
Normal 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 '========================================'
|
||||
39
tmp_update_conversations.rb
Normal file
39
tmp_update_conversations.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user