Compare commits
No commits in common. "main" and "feature/humanized-typing-adjustments" have entirely different histories.
main
...
feature/hu
13
.env.example
13
.env.example
@ -99,16 +99,3 @@ AIOS_VERSION=2.2.0
|
||||
# Custom Configuration
|
||||
# --------------------------------------------
|
||||
# Add your custom API keys below
|
||||
|
||||
# Reserva Rede 1001 — public reservations API (Fase 2)
|
||||
# Token used to authenticate calls from the reserva-1001 app to the public
|
||||
# reservation endpoint. Generate via `openssl rand -hex 32` in production.
|
||||
RESERVA_1001_API_TOKEN=
|
||||
|
||||
# Reserva Rede 1001 — URL base do app publico (usada pela Jasmine pra gerar links prefill)
|
||||
RESERVA_1001_BASE_URL=http://localhost:5180
|
||||
|
||||
# Reserva Rede 1001 — Supabase credentials para consultas de catalogo (preco, unidade)
|
||||
RESERVA_1001_SUPABASE_URL=
|
||||
RESERVA_1001_SUPABASE_ANON_KEY=
|
||||
RESERVA_1001_SUPABASE_SCHEMA=reserva_hotel
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -129,10 +129,3 @@ Thumbs.db
|
||||
.env.aios
|
||||
.env.backup*
|
||||
reference/chatwoot-develop
|
||||
|
||||
# Credentials / secrets — NUNCA commitar
|
||||
docs/acessos_vps.md
|
||||
docs/acessos*.md
|
||||
**/acessos_vps*
|
||||
**/*_secrets.md
|
||||
**/*.credentials
|
||||
|
||||
@ -89,14 +89,12 @@ Metrics/BlockLength:
|
||||
- spec/**/*
|
||||
- '**/routes.rb'
|
||||
- 'config/environments/*'
|
||||
- 'lib/tasks/**/*.rake'
|
||||
- db/schema.rb
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/seeders/message_seeder.rb
|
||||
- spec/support/slack_stubs.rb
|
||||
- app/models/concerns/landing_host_ai_syncable.rb
|
||||
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
@ -187,7 +185,6 @@ Metrics/AbcSize:
|
||||
- 'app/models/canned_response.rb'
|
||||
- 'app/models/telegram_bot.rb'
|
||||
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
|
||||
- 'app/models/concerns/landing_host_ai_syncable.rb'
|
||||
|
||||
Rails/RenderInline:
|
||||
Exclude:
|
||||
@ -241,7 +238,6 @@ AllCops:
|
||||
- 'reference/**/*'
|
||||
- '.aios-core/**/*'
|
||||
- '.claude/**/*'
|
||||
- 'scripts/captain_codex_poc/**/*'
|
||||
|
||||
FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
|
||||
@ -26,10 +26,6 @@ Layout/LineLength:
|
||||
|
||||
# Offense count: 2
|
||||
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
|
||||
Lint/DuplicateRescueException:
|
||||
Exclude:
|
||||
- 'app/models/concerns/landing_host_ai_syncable.rb'
|
||||
|
||||
Lint/DuplicateBranch:
|
||||
Exclude:
|
||||
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
|
||||
@ -48,7 +44,6 @@ Lint/UnusedMethodArgument:
|
||||
Metrics/AbcSize:
|
||||
Exclude:
|
||||
- 'app/models/channel/whatsapp.rb'
|
||||
- 'app/models/concerns/landing_host_ai_syncable.rb'
|
||||
- 'app/services/evolution_api/client.rb'
|
||||
- 'app/services/whatsapp/decryption_service.rb'
|
||||
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
|
||||
@ -64,10 +59,6 @@ Metrics/AbcSize:
|
||||
|
||||
# Offense count: 6
|
||||
# Configuration parameters: CountComments, Max, CountAsOne.
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- 'app/models/concerns/landing_host_ai_syncable.rb'
|
||||
|
||||
Metrics/ClassLength:
|
||||
Exclude:
|
||||
- 'app/models/channel/whatsapp.rb'
|
||||
|
||||
184
AGENTS.md
184
AGENTS.md
@ -4,49 +4,56 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Chatwoot** customizado para **fazer.ai** — plataforma de atendimento ao cliente multi-canal com IA (Captain). Multi-tenant SaaS para hotelaria, com integração de PIX/Inter, reservas e WhatsApp.
|
||||
This is a **Chatwoot** customer engagement platform (open-source alternative to Intercom/Zendesk), customized for **fazer.ai**. It includes the **Synkra AIOS** framework overlay for AI-orchestrated development workflows.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: Ruby 3.4.4 + Rails 7.1
|
||||
- Frontend: Vue 3 + Vite + Pinia
|
||||
- Database: PostgreSQL + pgvector
|
||||
- Background Jobs: Sidekiq (com sidekiq-cron)
|
||||
- Package Manager: **pnpm** (obrigatório — nunca npm/yarn)
|
||||
- Frontend: Vue 3 + Vite
|
||||
- Database: PostgreSQL with pgvector
|
||||
- Background Jobs: Sidekiq
|
||||
- Package Manager: **pnpm** (required, not npm/yarn)
|
||||
- Testing: RSpec (backend), Vitest (frontend)
|
||||
- Event system: Wisper (pub/sub)
|
||||
- Authorization: Pundit
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Iniciar aplicação
|
||||
### Starting the Application
|
||||
|
||||
```bash
|
||||
pnpm run dev # overmind: Rails :3000 + Sidekiq + Vite
|
||||
pnpm run start:dev # foreman (alternativo)
|
||||
# Development server (Rails backend + Sidekiq + Vite)
|
||||
pnpm run dev
|
||||
|
||||
# Individual processes:
|
||||
# - Rails backend: http://localhost:3001
|
||||
# - Sidekiq: background worker
|
||||
# - Vite: frontend dev server
|
||||
```
|
||||
|
||||
### Testes
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Frontend (Vitest) — CRÍTICO: sem -- com pnpm!
|
||||
pnpm test # todos
|
||||
pnpm test app/javascript/path # arquivo específico (NÃO use pnpm test -- <file>)
|
||||
pnpm test:watch
|
||||
pnpm test:coverage
|
||||
# Frontend (Vitest) - CRITICAL: NO -- flag with pnpm test!
|
||||
pnpm test # Run all tests
|
||||
pnpm test <file> # Run specific file (NOT pnpm test -- <file>)
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm test:coverage # Coverage report
|
||||
|
||||
# Backend (RSpec)
|
||||
bundle exec rspec # todos
|
||||
bundle exec rspec spec/models/contact_spec.rb # arquivo
|
||||
bundle exec rspec spec/models/contact_spec.rb:42 # linha específica
|
||||
bundle exec rspec # All specs
|
||||
bundle exec rspec spec/models/user_spec.rb # Specific file
|
||||
bundle exec rspec spec/models/user_spec.rb:42 # Specific line
|
||||
```
|
||||
|
||||
### Qualidade de código
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run eslint # lint JS/Vue
|
||||
pnpm run eslint:fix # auto-fix
|
||||
bundle exec rubocop # lint Ruby
|
||||
bundle exec rubocop -a # auto-fix Ruby
|
||||
# JavaScript/Vue linting
|
||||
pnpm run eslint # Check
|
||||
pnpm run eslint:fix # Auto-fix
|
||||
|
||||
# Ruby linting
|
||||
bundle exec rubocop # Check
|
||||
bundle exec rubocop -a # Auto-fix
|
||||
pnpm run ruby:prettier # Same as rubocop -a
|
||||
```
|
||||
|
||||
### Database
|
||||
@ -54,102 +61,72 @@ bundle exec rubocop -a # auto-fix Ruby
|
||||
```bash
|
||||
bin/rails db:migrate
|
||||
bin/rails db:rollback
|
||||
bin/rails db:reset && bin/rails db:seed
|
||||
bin/rails db:reset
|
||||
bin/rails db:seed
|
||||
```
|
||||
|
||||
### i18n
|
||||
## Architecture Overview
|
||||
|
||||
```bash
|
||||
pnpm run sync:i18n # sincroniza arquivo de tradução
|
||||
```
|
||||
|
||||
## Arquitetura Backend
|
||||
|
||||
### Modelo de dados central
|
||||
### Backend Structure
|
||||
|
||||
```
|
||||
Account (tenant raiz)
|
||||
├── Inbox (canal: WhatsApp, Email, Facebook, Instagram, Twilio...)
|
||||
│ └── Contact (cliente) via ContactInbox
|
||||
├── Conversation (central: status, priority, SLA, custom_attributes)
|
||||
│ ├── Message (conteúdo, attachments, sender)
|
||||
│ ├── Agent (assignee)
|
||||
│ └── Label
|
||||
├── AutomationRule
|
||||
├── Campaign
|
||||
└── Article (help center)
|
||||
app/
|
||||
├── controllers/ # API endpoints (API::V1::Accounts::*)
|
||||
├── models/ # ActiveRecord models
|
||||
├── services/ # Business logic (Whatsapp::Providers::*, etc.)
|
||||
├── jobs/ # Sidekiq background jobs
|
||||
├── listeners/ # Wisper event subscribers (pub/sub)
|
||||
├── builders/ # Complex object construction
|
||||
├── finders/ # Query objects
|
||||
├── policies/ # Pundit authorization
|
||||
└── javascript/ # Vue.js frontend
|
||||
|
||||
enterprise/app/ # Enterprise features (Captain AI, billing)
|
||||
```
|
||||
|
||||
### Padrões Rails usados
|
||||
**Key Patterns:**
|
||||
- **Services:** Business logic extracted from models
|
||||
- **Builders:** Construct complex objects
|
||||
- **Finders:** Encapsulate complex queries
|
||||
- **Listeners:** Event-driven using Wisper
|
||||
- **Policies:** Pundit for authorization
|
||||
- **Jobs:** All async work in Sidekiq
|
||||
|
||||
| Padrão | Onde | Função |
|
||||
|--------|------|--------|
|
||||
| **Services** | `app/services/` | Toda lógica de negócio fora dos models |
|
||||
| **Builders** | `app/builders/` | Construção de objetos complexos (ex: criar conversa + contato) |
|
||||
| **Finders** | `app/finders/` | Query objects encapsulados |
|
||||
| **Listeners** | `app/listeners/` | Subscribers Wisper para eventos de domínio |
|
||||
| **Policies** | `app/policies/` | Autorização Pundit por recurso |
|
||||
| **Jobs** | `app/jobs/` | Todo trabalho assíncrono via Sidekiq |
|
||||
|
||||
### Estrutura de controllers
|
||||
|
||||
```
|
||||
app/controllers/
|
||||
├── api/v1/accounts/{account_id}/ # Endpoints principais (Conversations, Contacts, Inboxes...)
|
||||
├── api/v1/widget/ # Chat widget público
|
||||
└── enterprise/api/v1/accounts/captain/ # Captain AI (enterprise)
|
||||
```
|
||||
|
||||
### Enterprise — Captain AI (`enterprise/app/`)
|
||||
|
||||
Camada fazer.ai sobre o Chatwoot base:
|
||||
|
||||
- **Models chave:** `Captain::Unit` (multi-unidade hoteleira), `Captain::Assistant`, `Captain::Reservation`, `Captain::PixCharge`, `Captain::Document`, `Captain::ConversationInsight`
|
||||
- **Integrações:** Inter API (pagamento PIX), WhatsApp, sincronização de reservas, webhooks
|
||||
- **AI features:** LLM (OpenAI), copilot, audio transcription, label suggestion, help center search
|
||||
- **Feature flags por account:** `captain_features` (Editor, Assistant, Copilot, LabelSuggestion, AudioTranscription, HelpCenterSearch)
|
||||
|
||||
## Arquitetura Frontend
|
||||
### Frontend Structure
|
||||
|
||||
```
|
||||
app/javascript/
|
||||
├── dashboard/ # Dashboard do agente (Vue 3 + Vue Router + Pinia)
|
||||
│ ├── routes/ # Componentes de página
|
||||
│ ├── store/ # Pinia stores (55+ módulos: conversations, contacts, captain*)
|
||||
│ ├── components/ # Componentes reutilizáveis
|
||||
│ ├── api/ # Clientes HTTP por recurso
|
||||
│ └── i18n/locale/ # Traduções (en + pt_BR SEMPRE)
|
||||
├── widget/ # Widget de chat embeddable
|
||||
├── sdk/ # SDK JS (build separado: pnpm run build:sdk)
|
||||
├── portal/ # Help center público
|
||||
└── shared/ # Utilities compartilhados
|
||||
├── dashboard/ # Agent dashboard (Vue 3 + Vue Router + Vuex)
|
||||
│ ├── routes/ # Page components
|
||||
│ ├── store/ # Vuex state
|
||||
│ ├── components/ # Reusable components
|
||||
│ ├── api/ # API clients
|
||||
│ └── i18n/ # Translations (en, pt_BR required!)
|
||||
├── widget/ # Customer chat widget
|
||||
├── sdk/ # Embeddable JavaScript SDK
|
||||
├── portal/ # Public help center
|
||||
└── shared/ # Shared utilities
|
||||
```
|
||||
|
||||
**Aliases Vite:**
|
||||
**Vite Import Aliases:**
|
||||
- `components` → `app/javascript/dashboard/components`
|
||||
- `dashboard` → `app/javascript/dashboard`
|
||||
- `helpers` → `app/javascript/shared/helpers`
|
||||
- `shared`, `widget`, `survey`, `v3` → diretórios equivalentes
|
||||
- `shared`, `widget`, `survey`, `v3` → respective directories
|
||||
|
||||
**Bibliotecas chave:** ProseMirror (rich text), ActionCable (real-time), Chart.js, Twilio Voice SDK
|
||||
## Critical Conventions
|
||||
|
||||
## Convenções críticas
|
||||
### fazer.ai Branding
|
||||
**ALWAYS** style as `fazer.ai` (lowercase with dot), **NEVER** `Fazer.ai` or `FAZER.AI`
|
||||
|
||||
### Branding
|
||||
`fazer.ai` — sempre minúsculo com ponto. Nunca `Fazer.ai` ou `FAZER.AI`.
|
||||
### Internationalization
|
||||
**ALWAYS include pt_BR translations** for any new user-facing text
|
||||
- Location: `app/javascript/dashboard/i18n/locale/{en,pt_BR}/`
|
||||
|
||||
### Internacionalização
|
||||
Qualquer texto visível ao usuário **exige** tradução em `en` e `pt_BR`:
|
||||
```
|
||||
app/javascript/dashboard/i18n/locale/en/
|
||||
app/javascript/dashboard/i18n/locale/pt_BR/
|
||||
```
|
||||
|
||||
### Novos canais / integrações
|
||||
Siga o padrão existente em `app/services/whatsapp/` ou `app/services/instagram/` — nunca coloque lógica de canal no controller.
|
||||
|
||||
### Background jobs
|
||||
Toda operação demorada vai para Sidekiq. Jobs em `app/jobs/`, enterprise em `enterprise/app/jobs/`.
|
||||
### Testing Philosophy
|
||||
- Add specs when modifying code (use judgment)
|
||||
- Test behavior, not implementation
|
||||
- Consider cross-stack impacts (backend ↔ frontend)
|
||||
|
||||
---
|
||||
|
||||
@ -169,10 +146,9 @@ This repository includes **Synkra AIOS** - an AI-orchestrated development system
|
||||
<!-- AIOS-MANAGED-START: quality -->
|
||||
## Quality Gates
|
||||
|
||||
- Rode `pnpm run eslint`
|
||||
- Rode `bundle exec rubocop`
|
||||
- Rode `pnpm test`
|
||||
- Rode `bundle exec rspec`
|
||||
- Rode `npm run lint`
|
||||
- Rode `npm run typecheck`
|
||||
- Rode `npm test`
|
||||
- Atualize checklist e file list da story antes de concluir
|
||||
<!-- AIOS-MANAGED-END: quality -->
|
||||
|
||||
|
||||
2
Gemfile
2
Gemfile
@ -1,6 +1,6 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '>= 3.4.4'
|
||||
ruby '3.4.4'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
backend: bin/rails s -p 3000
|
||||
backend: bin/rails s -p 3001
|
||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: bin/vite dev
|
||||
|
||||
@ -137,4 +137,3 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
<!-- Status: integração Mattermost ativa -->
|
||||
|
||||
@ -12,24 +12,14 @@ class V2::Reports::BotMetricsBuilder
|
||||
conversation_count: bot_conversations.count,
|
||||
message_count: bot_messages.count,
|
||||
resolution_rate: bot_resolution_rate.to_i,
|
||||
handoff_rate: total_handoff_rate.to_i,
|
||||
bot_resolutions_count: bot_resolutions_count,
|
||||
auto_handoffs_count: auto_handoffs_count,
|
||||
manual_takeovers_count: manual_takeovers_count
|
||||
handoff_rate: bot_handoff_rate.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_inbox_id
|
||||
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def bot_activated_inbox_ids
|
||||
@bot_activated_inbox_ids ||= begin
|
||||
ids = account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
filter_inbox_id ? ids & [filter_inbox_id] : ids
|
||||
end
|
||||
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
end
|
||||
|
||||
def bot_conversations
|
||||
@ -40,47 +30,14 @@ class V2::Reports::BotMetricsBuilder
|
||||
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
|
||||
end
|
||||
|
||||
def base_reporting_events
|
||||
scope = account.reporting_events.where(account_id: account.id, created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
|
||||
.select(:conversation_id)
|
||||
.where(name: :conversation_bot_resolved)
|
||||
.distinct.count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
# Auto handoff = Jasmine called bot_handoff! explicitly (loop, timeout, max_turns, intent)
|
||||
def auto_handoffs_count
|
||||
@auto_handoffs_count ||= base_reporting_events.joins(:conversation)
|
||||
.select(:conversation_id)
|
||||
.where(name: :conversation_bot_handoff)
|
||||
.distinct.count
|
||||
end
|
||||
|
||||
# Manual takeover = a human replied (via Chatwoot UI or WhatsApp echo) WITHOUT a bot_handoff
|
||||
# event being emitted for the same conversation. The bot itself uses sender_type 'Captain::Assistant',
|
||||
# so it's never counted here.
|
||||
def manual_takeovers_count
|
||||
@manual_takeovers_count ||= begin
|
||||
conv_ids_with_human_reply = bot_conversations
|
||||
.joins(:messages)
|
||||
.where(messages: { message_type: :outgoing })
|
||||
.where('messages.sender_type = ? OR messages.sender_type IS NULL', 'User')
|
||||
.distinct
|
||||
.pluck(:id)
|
||||
|
||||
conv_ids_with_auto_handoff = ReportingEvent.unscope(:order)
|
||||
.where(name: 'conversation_bot_handoff',
|
||||
conversation_id: conv_ids_with_human_reply)
|
||||
.distinct
|
||||
.pluck(:conversation_id)
|
||||
|
||||
(conv_ids_with_human_reply - conv_ids_with_auto_handoff).count
|
||||
end
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
@ -89,10 +46,9 @@ class V2::Reports::BotMetricsBuilder
|
||||
bot_resolutions_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
|
||||
# Total handoff = auto + manual (the gear that closes the math now)
|
||||
def total_handoff_rate
|
||||
def bot_handoff_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
|
||||
bot_handoffs_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
class V2::Reports::ConversionFunnelBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
# Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the
|
||||
# reception, card on arrival, etc. Anything that means the booking went through.
|
||||
PAID_STATUSES = %w[active completed confirmed].freeze
|
||||
|
||||
# Statuses we ignore from "created" (drafts are pre-save, never went live)
|
||||
IGNORED_CREATED_STATUSES = %w[draft].freeze
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def metrics
|
||||
{
|
||||
leads: leads_breakdown,
|
||||
reservations: reservations_breakdown,
|
||||
conversion_rates: conversion_rates_breakdown
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_inbox_id
|
||||
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def conversations_in_period
|
||||
@conversations_in_period ||= begin
|
||||
scope = account.conversations.where(created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope
|
||||
end
|
||||
end
|
||||
|
||||
def reservations_in_period
|
||||
@reservations_in_period ||= begin
|
||||
scope = account.captain_reservations.where(created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope.where.not(status: IGNORED_CREATED_STATUSES)
|
||||
end
|
||||
end
|
||||
|
||||
# Leads classified using the same logic as the "Novas × Retorno" tab:
|
||||
# new = no prior conversation in any inbox of the account
|
||||
# returning = had a prior conversation
|
||||
def leads_breakdown
|
||||
total = conversations_in_period.count
|
||||
return { total: 0, new: 0, returning: 0 } if total.zero?
|
||||
|
||||
conv_with_prior_ids = conversations_in_period
|
||||
.joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \
|
||||
'AND prev.account_id = conversations.account_id ' \
|
||||
'AND prev.id < conversations.id')
|
||||
.distinct
|
||||
.pluck(:id)
|
||||
|
||||
returning = conv_with_prior_ids.size
|
||||
{
|
||||
total: total,
|
||||
new: total - returning,
|
||||
returning: returning
|
||||
}
|
||||
end
|
||||
|
||||
def reservations_breakdown
|
||||
created = reservations_in_period.count
|
||||
paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||||
|
||||
{
|
||||
created: created,
|
||||
paid: paid
|
||||
}
|
||||
end
|
||||
|
||||
def conversion_rates_breakdown
|
||||
leads_total = conversations_in_period.count
|
||||
reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||||
reservations_created = reservations_in_period.count
|
||||
|
||||
{
|
||||
lead_to_paid_reservation: percent(reservations_paid, leads_total),
|
||||
lead_to_any_reservation: percent(reservations_created, leads_total),
|
||||
created_to_paid: percent(reservations_paid, reservations_created)
|
||||
}
|
||||
end
|
||||
|
||||
def percent(numerator, denominator)
|
||||
return 0 if denominator.to_i.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
end
|
||||
@ -1,79 +0,0 @@
|
||||
class V2::Reports::InboxBenchmarkingBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
PAID_STATUSES = V2::Reports::ConversionFunnelBuilder::PAID_STATUSES
|
||||
IGNORED_CREATED_STATUSES = V2::Reports::ConversionFunnelBuilder::IGNORED_CREATED_STATUSES
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
# Returns one row per inbox of the account, with leads + reservations + rate,
|
||||
# plus the brand name so the frontend can group by brand for benchmarking.
|
||||
def build
|
||||
return [] if range.blank?
|
||||
|
||||
inbox_brand_lookup = build_inbox_brand_lookup
|
||||
|
||||
account.inboxes.map do |inbox|
|
||||
brand_name = inbox_brand_lookup[inbox.id]
|
||||
|
||||
leads_total = leads_count_by_inbox[inbox.id] || 0
|
||||
reservations_created = reservations_created_by_inbox[inbox.id] || 0
|
||||
reservations_paid = reservations_paid_by_inbox[inbox.id] || 0
|
||||
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
brand_name: brand_name,
|
||||
leads_total: leads_total,
|
||||
reservations_created: reservations_created,
|
||||
reservations_paid: reservations_paid,
|
||||
conversion_rate: percent(reservations_paid, leads_total)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def leads_count_by_inbox
|
||||
@leads_count_by_inbox ||= account.conversations
|
||||
.where(created_at: range)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def reservations_created_by_inbox
|
||||
@reservations_created_by_inbox ||= account.captain_reservations
|
||||
.where(created_at: range)
|
||||
.where.not(status: IGNORED_CREATED_STATUSES)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def reservations_paid_by_inbox
|
||||
@reservations_paid_by_inbox ||= account.captain_reservations
|
||||
.where(created_at: range, status: PAID_STATUSES)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
# inbox_id => brand_name (or nil when there is no brand mapped for this inbox)
|
||||
def build_inbox_brand_lookup
|
||||
rows = Captain::UnitInbox
|
||||
.joins(captain_unit: :brand)
|
||||
.where(inbox_id: account.inboxes.select(:id))
|
||||
.pluck('captain_unit_inboxes.inbox_id, captain_brands.name')
|
||||
|
||||
rows.to_h
|
||||
end
|
||||
|
||||
def percent(numerator, denominator)
|
||||
return 0 if denominator.to_i.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
end
|
||||
@ -1,111 +0,0 @@
|
||||
class V2::Reports::InboxLeadsSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
ALLOWED_GROUP_BY = %w[day week month].freeze
|
||||
RETURN_THRESHOLD = '24 hours'.freeze
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
return [] if range.blank? || inbox.blank?
|
||||
|
||||
rows = ActiveRecord::Base.connection.exec_query(
|
||||
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
|
||||
)
|
||||
|
||||
rows.map do |row|
|
||||
{
|
||||
period: row['period'].iso8601,
|
||||
new_leads: row['new_leads'].to_i,
|
||||
returning: row['returning'].to_i,
|
||||
others: row['others'].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
|
||||
end
|
||||
|
||||
def group_by
|
||||
value = params[:group_by].to_s
|
||||
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
|
||||
end
|
||||
|
||||
def sql_bindings
|
||||
{
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
since: range.begin,
|
||||
until_t: range.end,
|
||||
group_by: group_by,
|
||||
return_threshold: RETURN_THRESHOLD
|
||||
}
|
||||
end
|
||||
|
||||
# Single CTE to classify each conversation in the period as:
|
||||
# * new_leads: contact has no prior conversation in any inbox of the account
|
||||
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
|
||||
# event occurred more than 24h before the new conversation
|
||||
# * others: prior conversation existed but was not resolved or was resolved <24h ago
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def sql
|
||||
<<~SQL.squish
|
||||
WITH period_conversations AS (
|
||||
SELECT id, contact_id, created_at
|
||||
FROM conversations
|
||||
WHERE account_id = :account_id
|
||||
AND inbox_id = :inbox_id
|
||||
AND created_at >= :since
|
||||
AND created_at < :until_t
|
||||
),
|
||||
classified AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.created_at,
|
||||
EXISTS (
|
||||
SELECT 1 FROM conversations prev
|
||||
WHERE prev.contact_id = c.contact_id
|
||||
AND prev.account_id = :account_id
|
||||
AND prev.id < c.id
|
||||
) AS has_prior,
|
||||
(
|
||||
SELECT MAX(re.created_at)
|
||||
FROM reporting_events re
|
||||
INNER JOIN conversations prev ON prev.id = re.conversation_id
|
||||
WHERE re.name = 'conversation_resolved'
|
||||
AND prev.contact_id = c.contact_id
|
||||
AND prev.account_id = :account_id
|
||||
AND prev.id < c.id
|
||||
) AS latest_prior_resolution_at
|
||||
FROM period_conversations c
|
||||
)
|
||||
SELECT
|
||||
date_trunc(:group_by, created_at) AS period,
|
||||
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
|
||||
COUNT(*) FILTER (
|
||||
WHERE has_prior
|
||||
AND latest_prior_resolution_at IS NOT NULL
|
||||
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
|
||||
) AS returning,
|
||||
COUNT(*) FILTER (
|
||||
WHERE has_prior
|
||||
AND (
|
||||
latest_prior_resolution_at IS NULL
|
||||
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
|
||||
)
|
||||
) AS others
|
||||
FROM classified
|
||||
GROUP BY period
|
||||
ORDER BY period
|
||||
SQL
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
@ -1,57 +0,0 @@
|
||||
# Proxy interno que traduz OpenAI Chat Completions ↔ OpenAI Responses (Codex).
|
||||
#
|
||||
# Recebe requests no formato Chat Completions (o que RubyLLM, Agents gem e
|
||||
# ruby-openai geram) e encaminha para a Responses API do ChatGPT Plus (Codex)
|
||||
# usando OAuth interno via Captain::Codex::AuthService.
|
||||
#
|
||||
# Rota: POST /codex/v1/chat/completions
|
||||
#
|
||||
# Acesso: interno (não autenticado — localhost-only via Docker network).
|
||||
# Em produção, o Nginx NÃO expõe /codex/* publicamente.
|
||||
class Api::Internal::CodexProxyController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
|
||||
def chat_completions
|
||||
chat_body = request.request_parameters.presence || parse_body
|
||||
return render_error('Empty request body', status: 400) if chat_body.blank?
|
||||
|
||||
render json: proxy_call(chat_body)
|
||||
rescue Captain::Codex::AuthService::AuthError, Captain::Codex::Client::Error, StandardError => e
|
||||
handle_proxy_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_proxy_error(error)
|
||||
case error
|
||||
when Captain::Codex::AuthService::AuthError
|
||||
Rails.logger.error("[Codex Proxy] Auth error: #{error.message}")
|
||||
render_error("Codex auth error: #{error.message}", status: 401)
|
||||
when Captain::Codex::Client::Error
|
||||
Rails.logger.error("[Codex Proxy] Upstream error: #{error.message}")
|
||||
render_error("Upstream error: #{error.message}", status: error.http_status || 502)
|
||||
else
|
||||
Rails.logger.error("[Codex Proxy] Unexpected: #{error.class} #{error.message}\n#{error.backtrace.first(5).join("\n")}")
|
||||
render_error("Internal error: #{error.message}", status: 500)
|
||||
end
|
||||
end
|
||||
|
||||
def proxy_call(chat_body)
|
||||
responses_body = Captain::Codex::Translator.chat_to_responses(chat_body)
|
||||
aggregated = Captain::Codex::Client.new.responses(responses_body)
|
||||
Captain::Codex::Translator.responses_to_chat(aggregated)
|
||||
end
|
||||
|
||||
def parse_body
|
||||
raw = request.raw_post
|
||||
return {} if raw.blank?
|
||||
|
||||
JSON.parse(raw)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def render_error(message, status:)
|
||||
render json: { error: { message: message, type: 'codex_proxy_error' } }, status: status
|
||||
end
|
||||
end
|
||||
@ -23,9 +23,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
user_attrs = agent_params.slice(:name).to_h.compact.symbolize_keys
|
||||
user_attrs[:ui_settings] = merged_ui_settings if agent_params[:ui_settings].present?
|
||||
@agent.update!(user_attrs) if user_attrs.any?
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||
end
|
||||
|
||||
@ -74,19 +72,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_agent_params
|
||||
[:name, :email, :role, :availability, :auto_offline, { ui_settings: [:aggressive_alert_inbox_ids_mode, { aggressive_alert_inbox_ids: [] }] }]
|
||||
[:name, :email, :role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
params.require(:agent).permit(allowed_agent_params)
|
||||
end
|
||||
|
||||
def merged_ui_settings
|
||||
existing = @agent.ui_settings || {}
|
||||
incoming = agent_params[:ui_settings].to_h.deep_stringify_keys
|
||||
existing.merge(incoming)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
# Fornece o CEO Digest (via Captain::Reports::CeoDigestService) para a UI
|
||||
# + endpoint de drill-down (busca conversas que contêm um texto) + disparo on-demand
|
||||
# do envio ao Mattermost.
|
||||
class Api::V1::Accounts::Captain::Reports::ExecutiveController < Api::V1::Accounts::BaseController
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/executive
|
||||
# Params: period_start, period_end
|
||||
def show
|
||||
period_end = parse_date(params[:period_end], Time.zone.today - 1)
|
||||
period_start = parse_date(params[:period_start], period_end - 6.days)
|
||||
|
||||
digest = Captain::Reports::CeoDigestService.new(
|
||||
account: Current.account,
|
||||
period_start: period_start,
|
||||
period_end: period_end
|
||||
).call
|
||||
|
||||
render json: digest
|
||||
end
|
||||
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/executive/drilldown
|
||||
# Params: query (texto a procurar), period_start, period_end, inbox_id (opcional)
|
||||
# Retorna: conversas que contêm o texto, com link para abrir no Chatwoot.
|
||||
def drilldown
|
||||
query = params[:query].to_s.strip
|
||||
return render json: { conversations: [] } if query.blank?
|
||||
|
||||
period_end = parse_date(params[:period_end], Time.zone.today - 1)
|
||||
period_start = parse_date(params[:period_start], period_end - 6.days)
|
||||
inbox_id = params[:inbox_id].presence&.to_i
|
||||
|
||||
conversations = search_conversations(query, period_start, period_end, inbox_id)
|
||||
tokens = extract_tokens(query)
|
||||
render json: { query: query, conversations: conversations, tokens: tokens }
|
||||
end
|
||||
|
||||
# POST /api/v1/accounts/:account_id/captain/reports/executive/deliver
|
||||
# Dispara entrega do digest ao Mattermost agora (usa config da conta).
|
||||
def deliver
|
||||
period_end = parse_date(params[:period_end], Time.zone.today - 1)
|
||||
period_start = parse_date(params[:period_start], period_end - 6.days)
|
||||
|
||||
Captain::Reports::CeoDigestJob.perform_later(Current.account.id, period_start, period_end)
|
||||
render json: { status: 'queued', period_start: period_start, period_end: period_end }, status: :accepted
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_conversations(query, period_start, period_end, inbox_id)
|
||||
tokens = extract_tokens(query)
|
||||
return [] if tokens.empty?
|
||||
|
||||
scope = Current.account.conversations
|
||||
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
||||
.joins(:messages)
|
||||
scope = scope.where(inbox_id: inbox_id) if inbox_id
|
||||
scope = apply_token_filter(scope, tokens)
|
||||
scope.distinct.includes(:contact, :inbox).limit(10).map { |c| format_conversation(c) }
|
||||
end
|
||||
|
||||
# Quebra a descrição do insight em palavras relevantes (4+ chars, sem stopwords)
|
||||
# e retorna só os tokens mais distintivos. Assim "Facilidade no acesso ao link
|
||||
# de pagamento" vira ["facilidade", "acesso", "link", "pagamento"].
|
||||
STOPWORDS = %w[
|
||||
para com que uma esse essa esta isso aqui ali mais menos sobre entre
|
||||
sem nao sim the and for that this with from into have need want
|
||||
sobre quando onde como porque qual quais nesse nessa dessa desse dele
|
||||
dela dos das pelo pela pelos pelas deste desta disto isto foi ser
|
||||
].freeze
|
||||
|
||||
def extract_tokens(query)
|
||||
query.to_s.downcase
|
||||
.scan(/[[:alnum:]áéíóúâêôãõçàü]+/)
|
||||
.reject { |w| w.length < 4 || STOPWORDS.include?(w) }
|
||||
.uniq
|
||||
.first(8)
|
||||
end
|
||||
|
||||
def apply_token_filter(scope, tokens)
|
||||
conditions = Array.new(tokens.size, 'messages.content ILIKE ?').join(' OR ')
|
||||
values = tokens.map { |t| "%#{sanitize_like(t)}%" }
|
||||
scope.where(conditions, *values)
|
||||
end
|
||||
|
||||
def sanitize_like(str)
|
||||
str.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_')
|
||||
end
|
||||
|
||||
def format_conversation(conv)
|
||||
{
|
||||
id: conv.display_id,
|
||||
internal_id: conv.id,
|
||||
status: conv.status,
|
||||
inbox_id: conv.inbox_id,
|
||||
inbox_name: conv.inbox&.name,
|
||||
contact_name: conv.contact&.name,
|
||||
created_at: conv.created_at,
|
||||
updated_at: conv.updated_at,
|
||||
url: conversation_url(conv)
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_url(conv)
|
||||
"/app/accounts/#{Current.account.id}/conversations/#{conv.display_id}"
|
||||
end
|
||||
|
||||
def parse_date(param, default)
|
||||
return default if param.blank?
|
||||
|
||||
Date.parse(param.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
default
|
||||
end
|
||||
end
|
||||
@ -1,15 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::Captain::Reports::FunnelController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
def show
|
||||
days = params[:period_days].to_i
|
||||
report = Captain::Reports::ConversionFunnelService.new(
|
||||
account: current_account,
|
||||
period_days: days
|
||||
).call
|
||||
render json: report
|
||||
end
|
||||
end
|
||||
@ -1,9 +1,19 @@
|
||||
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def index
|
||||
insights = filtered_insights.order(period_start: :desc).limit(12)
|
||||
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
||||
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
|
||||
|
||||
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
|
||||
scope = scope.where(captain_unit_id: unit_id) if unit_id
|
||||
scope = scope.where(inbox_id: inbox_id) if inbox_id
|
||||
scope = scope.where(captain_unit_id: nil, inbox_id: nil) if !unit_id && !inbox_id
|
||||
|
||||
insights = scope.order(period_start: :desc).limit(12)
|
||||
|
||||
render json: insights.map { |i| format_insight(i) }
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def show
|
||||
insight = Captain::ConversationInsight.find_by!(
|
||||
@ -22,32 +32,12 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
||||
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
||||
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
|
||||
|
||||
# Log parameters to help debugging
|
||||
Rails.logger.info '[Captain::Reports::InsightsController] Generating insight ' \
|
||||
"for Unit: #{unit_id}, Inbox: #{inbox_id}, Period: #{period_start} to #{period_end}"
|
||||
|
||||
enqueue_insight(unit_id, inbox_id, period_start, period_end)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
private
|
||||
|
||||
def filtered_insights
|
||||
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
|
||||
scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope = scope.for_period(*requested_period) if requested_period
|
||||
scope
|
||||
end
|
||||
|
||||
def filter_unit_id
|
||||
params[:unit_id].presence&.to_i
|
||||
end
|
||||
|
||||
def filter_inbox_id
|
||||
params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
|
||||
insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end)
|
||||
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
|
||||
@ -79,21 +69,11 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
||||
end
|
||||
|
||||
def parse_date(param, default)
|
||||
return default if param.blank?
|
||||
|
||||
Date.parse(param.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
param.present? ? Date.parse(param) : default
|
||||
rescue ArgumentError
|
||||
default
|
||||
end
|
||||
|
||||
def requested_period
|
||||
return nil if params[:period_start].blank? || params[:period_end].blank?
|
||||
|
||||
[Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)]
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def format_insight(insight)
|
||||
{
|
||||
id: insight.id,
|
||||
|
||||
@ -2,43 +2,30 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
|
||||
def show
|
||||
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month)
|
||||
period_end = parse_date(params[:period_end], Time.zone.today)
|
||||
render json: build_operational_report(find_unit, find_inbox, period_start, period_end)
|
||||
unit = params[:unit_id].present? ? Current.account.captain_units.find_by(id: params[:unit_id]) : nil
|
||||
|
||||
render json: build_operational_report(unit, period_start, period_end)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_unit
|
||||
return nil if params[:unit_id].blank?
|
||||
|
||||
Current.account.captain_units.find_by(id: params[:unit_id])
|
||||
end
|
||||
|
||||
def find_inbox
|
||||
return nil if params[:inbox_id].blank?
|
||||
|
||||
Current.account.inboxes.find_by(id: params[:inbox_id])
|
||||
end
|
||||
|
||||
def parse_date(param, default)
|
||||
param.present? ? Date.parse(param) : default
|
||||
rescue ArgumentError
|
||||
default
|
||||
end
|
||||
|
||||
def build_operational_report(unit, inbox, period_start, period_end)
|
||||
conversations = scoped_conversations(unit, inbox, period_start, period_end)
|
||||
def build_operational_report(unit, period_start, period_end)
|
||||
conversations = scoped_conversations(unit, period_start, period_end)
|
||||
|
||||
{
|
||||
period: { start: period_start, end: period_end },
|
||||
unit_id: unit&.id,
|
||||
unit_name: unit&.name,
|
||||
inbox_id: inbox&.id,
|
||||
inbox_name: inbox&.name,
|
||||
conversations: conversation_metrics(conversations),
|
||||
reservations: reservation_metrics(unit, inbox, period_start, period_end),
|
||||
reservations: reservation_metrics(unit, period_start, period_end),
|
||||
hourly_distribution: hourly_distribution(conversations),
|
||||
daily_distribution: daily_distribution(conversations, period_start, period_end),
|
||||
by_inbox: inbox.nil? ? by_inbox_breakdown(conversations) : []
|
||||
daily_distribution: daily_distribution(conversations, period_start, period_end)
|
||||
}
|
||||
end
|
||||
|
||||
@ -55,8 +42,8 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
|
||||
}
|
||||
end
|
||||
|
||||
def reservation_metrics(unit, inbox, period_start, period_end)
|
||||
reservations = scoped_reservations(unit, inbox, period_start, period_end)
|
||||
def reservation_metrics(unit, period_start, period_end)
|
||||
reservations = scoped_reservations(unit, period_start, period_end)
|
||||
paid = reservations.where(status: 'paid')
|
||||
expired = reservations.where(status: 'expired')
|
||||
|
||||
@ -86,54 +73,21 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
|
||||
end
|
||||
end
|
||||
|
||||
def scoped_conversations(unit, inbox, period_start, period_end)
|
||||
def scoped_conversations(unit, period_start, period_end)
|
||||
scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
||||
if inbox
|
||||
scope = scope.where(inbox_id: inbox.id)
|
||||
elsif unit
|
||||
if unit
|
||||
inbox_ids = unit.inboxes.pluck(:id)
|
||||
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
def scoped_reservations(unit, inbox, period_start, period_end)
|
||||
def scoped_reservations(unit, period_start, period_end)
|
||||
scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
||||
if inbox
|
||||
conversation_ids = Current.account.conversations.where(inbox_id: inbox.id).pluck(:id)
|
||||
scope = scope.where(conversation_id: conversation_ids)
|
||||
elsif unit
|
||||
scope = scope.where(captain_unit_id: unit.id)
|
||||
end
|
||||
scope = scope.where(captain_unit_id: unit.id) if unit
|
||||
scope
|
||||
end
|
||||
|
||||
def by_inbox_breakdown(conversations)
|
||||
resolved_int = Conversation.statuses['resolved']
|
||||
open_int = Conversation.statuses['open']
|
||||
inbox_data = conversations.group(:inbox_id).pluck(
|
||||
:inbox_id,
|
||||
Arel.sql('COUNT(*)'),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{resolved_int})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{open_int})")
|
||||
)
|
||||
inbox_names = Current.account.inboxes.where(id: inbox_data.map(&:first)).pluck(:id, :name).to_h
|
||||
|
||||
rows = inbox_data.map { |inbox_id, total, resolved, open| build_inbox_row(inbox_id, total, resolved, open, inbox_names) }
|
||||
rows.sort_by { |row| -row[:total] }
|
||||
end
|
||||
|
||||
def build_inbox_row(inbox_id, total, resolved, open, inbox_names)
|
||||
{
|
||||
inbox_id: inbox_id,
|
||||
inbox_name: inbox_names[inbox_id] || "Canal ##{inbox_id}",
|
||||
total: total,
|
||||
resolved: resolved,
|
||||
open: open,
|
||||
resolution_rate: safe_rate(resolved, total)
|
||||
}
|
||||
end
|
||||
|
||||
def avg_resolution_minutes(conversations)
|
||||
return 0 if conversations.none?
|
||||
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Endpoints de retenção e recorrência pra Relatórios > Retenção.
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/retention → summary (KPIs)
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/retention/cohort → matriz cohort
|
||||
class Api::V1::Accounts::Captain::Reports::RetentionController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
def show
|
||||
summary = Captain::Reports::RetentionSummaryService.new(
|
||||
account: current_account,
|
||||
period_start: params[:start_date],
|
||||
period_end: params[:end_date]
|
||||
).call
|
||||
|
||||
render json: summary
|
||||
end
|
||||
|
||||
def cohort
|
||||
months = params[:history_months].presence&.to_i
|
||||
data = Captain::Reports::RetentionCohortService.new(
|
||||
account: current_account,
|
||||
history_months: months || Captain::Reports::RetentionCohortService::DEFAULT_HISTORY_MONTHS
|
||||
).call
|
||||
|
||||
render json: data
|
||||
end
|
||||
end
|
||||
@ -1,10 +1,9 @@
|
||||
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_captain_enabled
|
||||
before_action :set_unit, only: [:show, :update, :destroy, :update_concierge]
|
||||
before_action :set_unit, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@units = Current.account.captain_units.includes(:inboxes)
|
||||
@units = filter_units_by_user_access(@units)
|
||||
render json: @units.map { |u| format_unit(u) }
|
||||
end
|
||||
|
||||
@ -39,32 +38,12 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def update_concierge
|
||||
return render json: { error: 'Unauthorized' }, status: :unauthorized unless Current.user.administrator?
|
||||
|
||||
@unit.update!(concierge_params)
|
||||
render json: format_unit(@unit)
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: { errors: @unit.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_captain_enabled
|
||||
# Dependendo da regra de negócio, pode-se verificar as features da conta aqui original
|
||||
end
|
||||
|
||||
# Administrador vê todas as unidades; agente só vê unidades vinculadas a
|
||||
# alguma caixa de entrada a qual ele tem acesso.
|
||||
def filter_units_by_user_access(scope)
|
||||
return scope if Current.user.administrator?
|
||||
|
||||
accessible_inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
||||
return scope.none if accessible_inbox_ids.empty?
|
||||
|
||||
scope.joins(:inboxes).where(inboxes: { id: accessible_inbox_ids }).distinct
|
||||
end
|
||||
|
||||
def default_brand
|
||||
@default_brand ||= Captain::Brand.where(account_id: Current.account.id).first ||
|
||||
Captain::Brand.create!(
|
||||
@ -90,13 +69,6 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
|
||||
)
|
||||
end
|
||||
|
||||
def concierge_params
|
||||
params.require(:captain_unit).permit(
|
||||
:concierge_inbox_id,
|
||||
concierge_config: [:persona_name, :knowledge, { variables: {} }]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox_ids_param
|
||||
return [] unless params[:captain_unit].key?(:inbox_ids)
|
||||
|
||||
@ -118,7 +90,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
|
||||
end
|
||||
end
|
||||
|
||||
def format_unit(unit) # rubocop:disable Metrics/AbcSize
|
||||
def format_unit(unit)
|
||||
inboxes = unit.inboxes.to_a
|
||||
{
|
||||
id: unit.id,
|
||||
@ -135,13 +107,6 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
|
||||
has_key: unit.inter_key_content.present? || unit.resolved_inter_key_path.present?,
|
||||
has_client_secret: unit.inter_client_secret.present?,
|
||||
proactive_pix_polling_enabled: unit.proactive_pix_polling_enabled
|
||||
}.merge(format_concierge(unit))
|
||||
end
|
||||
|
||||
def format_concierge(unit)
|
||||
{
|
||||
concierge_inbox_id: unit.concierge_inbox_id,
|
||||
concierge_config: unit.concierge_config || {}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@ -215,9 +215,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay,
|
||||
:message_signature_enabled, :message_signature_default_name, :message_signature_day_name,
|
||||
:message_signature_night_even_name, :message_signature_night_odd_name,
|
||||
:message_signature_night_shift_start, :message_signature_night_shift_end,
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox, only: [:index, :create]
|
||||
before_action :fetch_landing_host, only: [:update, :destroy]
|
||||
|
||||
def index
|
||||
@landing_hosts = LandingHost.where(inbox_id: @inbox.id)
|
||||
render json: @landing_hosts
|
||||
end
|
||||
|
||||
def create
|
||||
@landing_host = LandingHost.new(landing_host_params.merge(inbox_id: @inbox.id, active: true))
|
||||
|
||||
if @landing_host.save
|
||||
render json: @landing_host, status: :created
|
||||
else
|
||||
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @landing_host.update(landing_host_params)
|
||||
render json: @landing_host
|
||||
else
|
||||
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@landing_host.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Inbox not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def fetch_landing_host
|
||||
# Garantimos que a pessoa só possa acessar/apagar LandingHosts de Inboxes que pertencem a ela
|
||||
valid_inbox_ids = Current.account.inboxes.pluck(:id)
|
||||
@landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Landing Host not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def landing_host_params
|
||||
params.require(:landing_host).permit(
|
||||
:hostname, :unit_code, :active, :auto_label,
|
||||
:page_title, :page_subtitle, :button_text, :logo_url,
|
||||
:suite_image_url, :theme_color, :whatsapp_number,
|
||||
:initial_message, :default_source, :default_campanha,
|
||||
custom_config: {}
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -1,83 +0,0 @@
|
||||
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
|
||||
def show
|
||||
clicks = account_clicks
|
||||
render json: stats_payload(clicks)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_clicks
|
||||
scope = LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
|
||||
scope = scope.where(inbox_id: params[:inbox_id]) if params[:inbox_id].present?
|
||||
apply_period_filter(scope)
|
||||
end
|
||||
|
||||
def apply_period_filter(scope)
|
||||
return scope unless params[:period_start].present? && params[:period_end].present?
|
||||
|
||||
start_at = Time.zone.parse(params[:period_start].to_s)&.beginning_of_day
|
||||
end_at = Time.zone.parse(params[:period_end].to_s)&.end_of_day
|
||||
return scope unless start_at && end_at
|
||||
|
||||
scope.where(created_at: start_at..end_at)
|
||||
end
|
||||
|
||||
def stats_payload(clicks)
|
||||
total_clicks = clicks.count
|
||||
total_conversions = clicks.where.not(conversation_id: nil).count
|
||||
total_non_converted = total_clicks - total_conversions
|
||||
|
||||
{
|
||||
total_clicks: total_clicks,
|
||||
total_conversions: total_conversions,
|
||||
total_non_converted: total_non_converted,
|
||||
drop_off_rate: percentage(total_non_converted, total_clicks),
|
||||
conversion_rate: percentage(total_conversions, total_clicks),
|
||||
unique_click_ids: clicks.where.not(click_id: [nil, '']).distinct.count(:click_id),
|
||||
unique_converted_contacts: clicks.where.not(contact_id: nil).distinct.count(:contact_id),
|
||||
daily: daily_breakdown(clicks),
|
||||
by_source: group_by(clicks, :source),
|
||||
by_campaign: group_by(clicks, :campanha),
|
||||
by_hostname: group_by(clicks, :hostname)
|
||||
}
|
||||
end
|
||||
|
||||
def percentage(part, total)
|
||||
return 0 unless total.positive?
|
||||
|
||||
(part.to_f / total * 100).round(1)
|
||||
end
|
||||
|
||||
def group_by(clicks, column)
|
||||
rows = clicks
|
||||
.group(column)
|
||||
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
|
||||
|
||||
grouped_rows = rows.map do |row|
|
||||
{
|
||||
label: row.public_send(column).presence || '(sem nome)',
|
||||
clicks: row.clicks,
|
||||
conversions: row.conversions,
|
||||
rate: row.clicks.positive? ? (row.conversions.to_f / row.clicks * 100).round(1) : 0
|
||||
}
|
||||
end
|
||||
|
||||
grouped_rows.sort_by { |row| -row[:clicks] }
|
||||
end
|
||||
|
||||
def daily_breakdown(clicks)
|
||||
rows = clicks
|
||||
.group('DATE(lead_clicks.created_at)')
|
||||
.select('DATE(lead_clicks.created_at) AS day, COUNT(*) AS clicks, COUNT(lead_clicks.conversation_id) AS conversions')
|
||||
.order('day ASC')
|
||||
|
||||
rows.map do |row|
|
||||
{
|
||||
day: row.day.to_s,
|
||||
clicks: row.clicks.to_i,
|
||||
conversions: row.conversions.to_i,
|
||||
non_converted: row.clicks.to_i - row.conversions.to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -96,8 +96,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||
:aggressive_alert_enabled]
|
||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
class Api::V1::TrackingController < ActionController::API
|
||||
def click
|
||||
LeadClick.create!(click_params)
|
||||
head :no_content
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error processing tracking click: #{e.message}")
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolved_inbox_id
|
||||
host = params[:hostname].to_s.strip.sub(%r{^https?://}, '')
|
||||
LandingHost.find_by(hostname: host, active: true)&.inbox_id
|
||||
end
|
||||
|
||||
def click_params
|
||||
base_params = {
|
||||
inbox_id: resolved_inbox_id,
|
||||
ip: params[:ip].presence || request.remote_ip,
|
||||
user_agent: request.user_agent || params[:user_agent],
|
||||
hostname: params[:hostname].to_s.strip,
|
||||
source: params[:source],
|
||||
campanha: params[:campanha],
|
||||
lp: params[:lp],
|
||||
click_id: params[:click_id],
|
||||
status: :clicked
|
||||
}
|
||||
|
||||
# Se 'lp' for fornecido, extraímos os UTMs se fonte ou campanha estiverem vazios
|
||||
if base_params[:lp].present?
|
||||
begin
|
||||
uri = URI.parse(base_params[:lp])
|
||||
query = Rack::Utils.parse_nested_query(uri.query)
|
||||
base_params[:source] ||= query['utm_source']
|
||||
base_params[:campanha] ||= query['utm_campaign']
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Error parsing LP URL for UTMs: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
base_params
|
||||
end
|
||||
end
|
||||
@ -1,4 +1,3 @@
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
include Api::V2::Accounts::ReportsHelper
|
||||
include Api::V2::Accounts::HeatmapHelper
|
||||
@ -59,7 +58,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def bot_metrics
|
||||
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, bot_metrics_params).metrics
|
||||
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
@ -88,23 +87,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def inbox_leads_summary
|
||||
return head :unprocessable_entity if params[:inbox_id].blank?
|
||||
|
||||
builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def conversion_funnel
|
||||
builder = V2::Reports::ConversionFunnelBuilder.new(Current.account, conversion_funnel_params)
|
||||
render json: builder.metrics
|
||||
end
|
||||
|
||||
def inbox_benchmarking
|
||||
builder = V2::Reports::InboxBenchmarkingBuilder.new(Current.account, inbox_benchmarking_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@ -206,37 +188,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def bot_metrics_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_leads_summary_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
group_by: params[:group_by],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def conversion_funnel_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_benchmarking_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Endpoint público para criação de reservas via React app externo (Reserva 1001).
|
||||
# Autenticado por token estático via header X-Reserva-Token.
|
||||
class Public::Api::V1::Captain::PublicReservationsController < ActionController::API
|
||||
before_action :authenticate_reserva_token!
|
||||
|
||||
def create # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
||||
unit = Captain::Unit.find_by(id: params[:chatwoot_unit_id])
|
||||
return render(json: { error: 'unit_not_found' }, status: :not_found) if unit.nil?
|
||||
return render(json: { error: 'unit_has_no_inbox' }, status: :unprocessable_entity) if unit.inbox_id.blank?
|
||||
return render(json: { error: 'unit_missing_inter_credentials' }, status: :unprocessable_entity) unless unit.inter_credentials_present?
|
||||
|
||||
customer = params[:customer] || {}
|
||||
return render(json: { error: 'customer_required' }, status: :unprocessable_entity) if customer[:name].blank?
|
||||
return render(json: { error: 'customer_phone_required' }, status: :unprocessable_entity) if customer[:phone].blank?
|
||||
|
||||
account = unit.account
|
||||
inbox = Inbox.find(unit.inbox_id)
|
||||
|
||||
# WhatsApp inbox exige source_id com apenas digitos (padrao E.164 sem o +)
|
||||
phone_digits = customer[:phone].to_s.gsub(/\D/, '')
|
||||
return render(json: { error: 'customer_phone_invalid' }, status: :unprocessable_entity) if phone_digits.empty? || phone_digits.length > 15
|
||||
|
||||
normalized_phone = "+#{phone_digits}"
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: phone_digits,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: customer[:name],
|
||||
phone_number: normalized_phone,
|
||||
email: customer[:email].presence,
|
||||
custom_attributes: {
|
||||
cpf: customer[:cpf].presence
|
||||
}.compact,
|
||||
additional_attributes: {
|
||||
origem: 'reserva-1001'
|
||||
}
|
||||
}
|
||||
).perform
|
||||
|
||||
# Atualiza campos visiveis do contato (alem do ContactInboxBuilder, que so
|
||||
# preenche no create e nao mexe em contato ja existente).
|
||||
persist_customer_metadata!(contact_inbox.contact, customer, params)
|
||||
|
||||
conversation = ConversationBuilder.new(
|
||||
params: ActionController::Parameters.new(
|
||||
additional_attributes: {
|
||||
source: 'reserva-1001',
|
||||
reserva_category: params[:category],
|
||||
reserva_stay_type: params[:stay_type],
|
||||
reserva_checkin_at: params[:checkin_at]
|
||||
}
|
||||
),
|
||||
contact_inbox: contact_inbox
|
||||
).perform
|
||||
|
||||
# Nota: o model Captain::Reservation#post_internal_reservation_note ja cria
|
||||
# a nota interna automaticamente via after_create_commit. Nao duplicamos aqui.
|
||||
|
||||
reservation = Captain::Reservation.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact_inbox.contact,
|
||||
contact_inbox: contact_inbox,
|
||||
conversation: conversation,
|
||||
unit: unit,
|
||||
suite_identifier: "#{params[:category]} · #{params[:stay_type]}",
|
||||
check_in_at: params[:checkin_at],
|
||||
check_out_at: checkout_from(params[:checkin_at], params[:stay_type]),
|
||||
status: :draft,
|
||||
payment_status: 'pending',
|
||||
total_amount: (params[:total_cents].to_i / 100.0),
|
||||
metadata: {
|
||||
origem: 'reserva-1001',
|
||||
category: params[:category],
|
||||
stay_type: params[:stay_type],
|
||||
deposit_cents: params[:deposit_cents].to_i,
|
||||
notes: params[:notes]
|
||||
}
|
||||
)
|
||||
|
||||
mark_conversation_as_awaiting_payment(conversation)
|
||||
|
||||
deposit_amount = (params[:deposit_cents].to_i / 100.0)
|
||||
charge = Captain::Inter::CobService.new(reservation, amount: deposit_amount).call
|
||||
reservation.update!(status: :pending_payment)
|
||||
|
||||
render json: {
|
||||
reservation_id: reservation.id,
|
||||
conversation_id: conversation.id,
|
||||
pix: {
|
||||
txid: charge.txid,
|
||||
copia_e_cola: charge.pix_copia_e_cola,
|
||||
qrcode_base64: nil,
|
||||
expires_at: (Time.current + Captain::PixCharge::EXPIRATION_SECONDS.seconds).iso8601
|
||||
}
|
||||
}, status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error("[PublicReservations] validation error: #{e.message}")
|
||||
render json: { error: 'validation_failed', details: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[PublicReservations] unexpected error: #{e.class} - #{e.message}")
|
||||
render json: { error: 'internal_error', message: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def status
|
||||
reservation = Captain::Reservation.find_by(id: params[:id])
|
||||
return render(json: { error: 'not_found' }, status: :not_found) if reservation.nil?
|
||||
|
||||
render json: {
|
||||
reservation_id: reservation.id,
|
||||
status: reservation.payment_status
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_reserva_token!
|
||||
expected = ENV.fetch('RESERVA_1001_API_TOKEN', nil)
|
||||
provided = request.headers['X-Reserva-Token']
|
||||
|
||||
if expected.blank?
|
||||
Rails.logger.error('[PublicReservations] RESERVA_1001_API_TOKEN not configured')
|
||||
render json: { error: 'service_unavailable' }, status: :service_unavailable and return
|
||||
end
|
||||
|
||||
return if provided.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
|
||||
|
||||
render json: { error: 'unauthorized' }, status: :unauthorized
|
||||
end
|
||||
|
||||
# Persiste CPF, email, ultima suite/permanencia, data e total de reservas
|
||||
# no contact.custom_attributes para aparecer no painel lateral do Chatwoot
|
||||
# e pra facilitar reservas futuras (cliente recorrente).
|
||||
def persist_customer_metadata!(contact, customer, payload) # rubocop:disable Metrics/AbcSize
|
||||
return if contact.blank?
|
||||
|
||||
current_custom = contact.custom_attributes || {}
|
||||
current_custom = current_custom.dup
|
||||
|
||||
current_custom['cpf'] = customer[:cpf] if customer[:cpf].present?
|
||||
current_custom['ultima_suite'] = payload[:category] if payload[:category].present?
|
||||
current_custom['ultima_permanencia'] = payload[:stay_type] if payload[:stay_type].present?
|
||||
current_custom['ultima_reserva_em'] = Time.current.iso8601
|
||||
current_custom['total_reservas'] = (current_custom['total_reservas'].to_i + 1)
|
||||
|
||||
updates = { custom_attributes: current_custom }
|
||||
updates[:email] = customer[:email] if customer[:email].present? && contact.email.blank?
|
||||
updates[:name] = customer[:name] if customer[:name].present? && contact.name.blank?
|
||||
|
||||
contact.update!(updates)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[PublicReservations] persist_customer_metadata failed: #{e.message}")
|
||||
end
|
||||
|
||||
# Espelha Captain::Tools::GeneratePixTool#mark_conversation_as_awaiting_payment
|
||||
# (enterprise/app/services/captain/tools/generate_pix_tool.rb:713-721)
|
||||
def mark_conversation_as_awaiting_payment(conversation)
|
||||
current = conversation.label_list
|
||||
merged = (current + ['aguardando_pagamento']).uniq
|
||||
merged -= %w[pagamento_confirmado reserva_feita]
|
||||
conversation.update_labels(merged)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[PublicReservations] label update failed: #{e.message}")
|
||||
# Não falha a request por causa disso
|
||||
end
|
||||
|
||||
def build_initial_note(payload)
|
||||
<<~NOTE.strip
|
||||
Nova reserva via reserva.1001
|
||||
Categoria: #{payload[:category]}
|
||||
Permanencia: #{payload[:stay_type]}
|
||||
Check-in: #{payload[:checkin_at]}
|
||||
Total: R$ #{format('%.2f', payload[:total_cents].to_i / 100.0)}
|
||||
Entrada (PIX 50%): R$ #{format('%.2f', payload[:deposit_cents].to_i / 100.0)}
|
||||
Observacao: #{payload[:notes].presence || '-'}
|
||||
NOTE
|
||||
end
|
||||
|
||||
def checkout_from(checkin_iso, stay_type)
|
||||
checkin = Time.zone.parse(checkin_iso.to_s)
|
||||
hours = case stay_type.to_s.downcase
|
||||
when '2hrs' then 2
|
||||
when '3hrs' then 3
|
||||
when 'pernoite' then 12
|
||||
when 'diaria', 'diária' then 24
|
||||
else 4 # default: 4hrs (inclui '4hrs' e qualquer outro valor)
|
||||
end
|
||||
checkin + hours.hours
|
||||
end
|
||||
end
|
||||
@ -1,20 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Endpoint público disparado pelo frontend (reserva-1001 /roleta/:token) assim que
|
||||
# o prêmio é revelado. Só enfileira o job — todo o trabalho (validação, claim atômico,
|
||||
# envio de msg) acontece em Captain::Roleta::NotifyRevealedJob.
|
||||
class Public::Api::V1::Captain::RouletteNotificationsController < ActionController::API
|
||||
def create
|
||||
token = params[:token].to_s.strip
|
||||
if token.blank?
|
||||
render json: { error: 'token ausente' }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
Captain::Roleta::NotifyRevealedJob.perform_later(token)
|
||||
render json: { enqueued: true }, status: :accepted
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[RouletteNotifications] erro: #{e.class} - #{e.message}"
|
||||
render json: { error: 'Internal error' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@ -1,23 +0,0 @@
|
||||
class Public::LandingPagesController < PublicController
|
||||
layout false
|
||||
|
||||
def show
|
||||
host = request.host.to_s.sub(/^www\./, '')
|
||||
@landing_host = LandingHost.find_by(hostname: host, active: true)
|
||||
|
||||
# Fallback local para testes
|
||||
return unless Rails.env.development? && @landing_host.nil?
|
||||
|
||||
@landing_host = LandingHost.first || LandingHost.new(
|
||||
page_title: 'Atendimento Express',
|
||||
page_subtitle: 'Clique e fale direto com a recepcao agora',
|
||||
whatsapp_number: '556136131003',
|
||||
initial_message: 'Ola! Tenho interesse.',
|
||||
theme_color: '#27c15b',
|
||||
logo_url: 'https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg',
|
||||
unit_code: 'express',
|
||||
default_source: 'direto',
|
||||
default_campanha: 'site'
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -1,43 +0,0 @@
|
||||
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
|
||||
#
|
||||
# Construtor responde async via POST pra esta URL com:
|
||||
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
|
||||
#
|
||||
# Este controller identifica a sessão do admin (por session_id no metadata
|
||||
# OU pelo cache key derivado de account_id que veio na query string) e
|
||||
# armazena a resposta no Rails.cache pra UI poder ler via polling.
|
||||
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
|
||||
def process_payload
|
||||
content = params[:content].to_s.strip
|
||||
return head :bad_request if content.blank?
|
||||
|
||||
session_key = resolve_session_key
|
||||
if session_key.blank?
|
||||
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
|
||||
return head :ok
|
||||
end
|
||||
|
||||
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
|
||||
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
|
||||
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
|
||||
head :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Hermes nao propaga chat_id no metadata da resposta de callback, entao
|
||||
# usamos a ultima sessao ativa do account (gravada por
|
||||
# HermesBuilder::Storage.remember_last_session no /start e /create).
|
||||
# MVP-safe pra 1 admin por vez por conta.
|
||||
def resolve_session_key
|
||||
account_id = params[:account_id]
|
||||
return nil if account_id.blank?
|
||||
|
||||
HermesBuilder::Storage.last_session_for(account_id)
|
||||
end
|
||||
end
|
||||
@ -1,226 +0,0 @@
|
||||
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
|
||||
#
|
||||
# Fluxo:
|
||||
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
|
||||
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
|
||||
# 2. Hermes processa via subscription Codex/etc dele.
|
||||
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
|
||||
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
|
||||
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
|
||||
# 4. Este controller cria a mensagem outgoing na conversation correta.
|
||||
#
|
||||
# Identificação da conversation: como o Hermes não preserva metadata customizado
|
||||
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
|
||||
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
|
||||
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
|
||||
class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
RECENT_WINDOW = 5.minutes
|
||||
|
||||
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
|
||||
# (quando o agente não sabe responder e quer escalar pra humano). NÃO
|
||||
# bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
|
||||
# próximas msgs não dispararem Hermes.
|
||||
HANDOFF_PATTERNS = [
|
||||
/\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
|
||||
/\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
|
||||
/\A\s*aguarde\s+um\s+instante/i
|
||||
].freeze
|
||||
|
||||
# Loop detection: 2 sinais combinados.
|
||||
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
|
||||
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
|
||||
# repetiu pergunta sobre o mesmo tópico.
|
||||
LOOP_SIMILARITY_THRESHOLD = 0.50
|
||||
LOOP_TOPIC_KEYWORD_OVERLAP = 3
|
||||
LOOP_STOPWORDS = %w[
|
||||
voce voces para por pra como mas isso esse essa estou esta este aqui ali
|
||||
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
|
||||
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
|
||||
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
|
||||
obrigado obrigada valeu vlw thanks por favor please
|
||||
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
|
||||
em com sem sob sobre antes apos depois entre meio tudo todo toda
|
||||
perfeito otimo certinho confirma confirme
|
||||
].freeze
|
||||
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
before_action :verify_signature
|
||||
before_action :fetch_inbox
|
||||
|
||||
def process_payload
|
||||
content = extract_content
|
||||
return head :bad_request if content.blank?
|
||||
|
||||
conversation = recent_conversation_for(@inbox)
|
||||
return log_no_conversation_and_ack if conversation.blank?
|
||||
|
||||
log_reply(conversation, content)
|
||||
detect_handoff_or_loop(conversation, content)
|
||||
deliver_outgoing(conversation, content)
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
head :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
|
||||
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
|
||||
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
|
||||
# pergunta reformulada) e escala.
|
||||
def detect_handoff_or_loop(conversation, content)
|
||||
if handoff_response?(content)
|
||||
mark_for_human_triage(conversation, reason: 'handoff_intencional')
|
||||
elsif looped_response?(conversation, content)
|
||||
mark_for_human_triage(conversation, reason: 'loop_detectado')
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_outgoing(conversation, content)
|
||||
if defined?(Captain::Hermes::DelayedReplyJob)
|
||||
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
|
||||
else
|
||||
create_outgoing_message(conversation, content)
|
||||
end
|
||||
end
|
||||
|
||||
def handoff_response?(content)
|
||||
return false if content.blank?
|
||||
|
||||
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
|
||||
end
|
||||
|
||||
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
|
||||
# outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
|
||||
# agente está repetindo pergunta/resposta sem progredir — geralmente
|
||||
# cliente fora do escopo (operadora telefonia, banco, suporte de outro
|
||||
# app, etc) OU fluxo travado.
|
||||
def looped_response?(conversation, content)
|
||||
prev = conversation.messages
|
||||
.where(message_type: :outgoing)
|
||||
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
|
||||
.reorder(created_at: :desc)
|
||||
.limit(1)
|
||||
.pick(:content)
|
||||
return false if prev.blank?
|
||||
|
||||
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
|
||||
|
||||
repeated_question?(content, prev)
|
||||
end
|
||||
|
||||
def similarity(text_a, text_b)
|
||||
set_a = tokenize(text_a)
|
||||
set_b = tokenize(text_b)
|
||||
return 0.0 if set_a.empty? || set_b.empty?
|
||||
|
||||
intersection = (set_a & set_b).size
|
||||
union = (set_a | set_b).size
|
||||
intersection.to_f / union
|
||||
end
|
||||
|
||||
# Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
|
||||
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
|
||||
def repeated_question?(text_a, text_b)
|
||||
return false unless inquisitive?(text_a) && inquisitive?(text_b)
|
||||
|
||||
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
|
||||
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
|
||||
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
|
||||
end
|
||||
|
||||
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
|
||||
|
||||
def inquisitive?(text)
|
||||
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
|
||||
end
|
||||
|
||||
def tokenize(text)
|
||||
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
|
||||
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
|
||||
end
|
||||
|
||||
def mark_for_human_triage(conversation, reason: nil)
|
||||
current = conversation.label_list
|
||||
conversation.update_labels((current + %w[triagem_humana]).uniq)
|
||||
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
|
||||
if inbox_id.present?
|
||||
@inbox = Inbox.find_by(id: inbox_id)
|
||||
elsif (slug = params[:slug].presence)
|
||||
# Resolve via slug (hermes_profile_name) — admin pode re-apontar a
|
||||
# inbox pra qualquer agente Hermes sem mexer em URL de callback.
|
||||
asst = Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
|
||||
ci = asst&.captain_inboxes&.first
|
||||
@inbox = ci&.inbox
|
||||
end
|
||||
head :not_found if @inbox.blank?
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
secret = Captain::Hermes.callback_signing_secret
|
||||
return true if secret.blank? # validação desabilitada (PoC sem secret)
|
||||
|
||||
signature = request.headers['X-Hermes-Callback-Signature'].to_s
|
||||
return head :unauthorized if signature.blank?
|
||||
|
||||
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def recent_conversation_for(inbox)
|
||||
inbox.conversations
|
||||
.where('updated_at >= ?', RECENT_WINDOW.ago)
|
||||
.where(status: %w[pending open])
|
||||
.reorder(updated_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def log_no_conversation_and_ack
|
||||
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
|
||||
head :ok
|
||||
end
|
||||
|
||||
def extract_content
|
||||
normalize_for_whatsapp(params[:content].to_s.strip)
|
||||
end
|
||||
|
||||
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
|
||||
# **negrito** -> *negrito*
|
||||
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
|
||||
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
|
||||
def normalize_for_whatsapp(content)
|
||||
return content if content.blank?
|
||||
|
||||
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
|
||||
end
|
||||
|
||||
def log_reply(conversation, content)
|
||||
Rails.logger.info(
|
||||
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
|
||||
)
|
||||
end
|
||||
|
||||
def create_outgoing_message(conversation, content)
|
||||
assistant = conversation.inbox.captain_assistant
|
||||
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
sender: sender,
|
||||
content: content,
|
||||
content_attributes: {
|
||||
external_source: 'hermes_callback'
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -1,110 +0,0 @@
|
||||
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
|
||||
#
|
||||
# POST /webhooks/captain/mcp
|
||||
#
|
||||
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
|
||||
# Captain (add_label, faq_lookup, generate_pix, etc).
|
||||
#
|
||||
# Conexão pelo Hermes:
|
||||
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
|
||||
#
|
||||
# Auth: aceita 2 modos (qualquer um basta):
|
||||
# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer <CAPTAIN_MCP_SECRET>`
|
||||
# É o que `hermes mcp add --auth header` usa nativamente.
|
||||
# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=<hex>`
|
||||
# Para clientes que preferem assinar o body inteiro.
|
||||
# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio,
|
||||
# validação é desabilitada (PoC/dev).
|
||||
#
|
||||
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
|
||||
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
|
||||
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
|
||||
# esse contexto pra resolver a conversa correta.
|
||||
class Webhooks::Captain::McpController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
before_action :verify_signature
|
||||
|
||||
def process_payload
|
||||
request_body = parse_request_body
|
||||
return head :bad_request if request_body.blank?
|
||||
|
||||
response = Captain::Mcp::Server.handle(
|
||||
request_body,
|
||||
context: extract_context(request_body)
|
||||
)
|
||||
|
||||
return head :ok if response.nil? # MCP notifications
|
||||
|
||||
render json: response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_request_body
|
||||
JSON.parse(request.raw_post)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
|
||||
return true if secret.blank?
|
||||
|
||||
return true if bearer_token_matches?(secret)
|
||||
return true if hmac_signature_matches?(secret)
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
|
||||
def bearer_token_matches?(secret)
|
||||
auth_header = request.headers['Authorization'].to_s
|
||||
return false unless auth_header.start_with?('Bearer ')
|
||||
|
||||
token = auth_header.delete_prefix('Bearer ').strip
|
||||
ActiveSupport::SecurityUtils.secure_compare(token, secret)
|
||||
end
|
||||
|
||||
def hmac_signature_matches?(secret)
|
||||
signature = request.headers['X-Hub-Signature-256'].to_s
|
||||
return false if signature.blank?
|
||||
|
||||
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
||||
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
||||
end
|
||||
|
||||
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
|
||||
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
|
||||
# é (já que MCP em si é stateless entre client/server).
|
||||
#
|
||||
# Fallback: cada profile do Hermes está atrelado a uma unidade
|
||||
# (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos
|
||||
# contexto via headers HTTP fixos no config.yaml do profile:
|
||||
# X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id.
|
||||
# Body wins se houver conflito (override por chamada).
|
||||
def extract_context(request_body)
|
||||
params = request_body['params'] || {}
|
||||
body_ctx = params['_captain_context'] || {}
|
||||
body_ctx = {} unless body_ctx.is_a?(Hash)
|
||||
|
||||
extract_header_context.merge(body_ctx.symbolize_keys)
|
||||
end
|
||||
|
||||
def extract_header_context
|
||||
{
|
||||
account_id: header_int('X-Captain-Account-Id'),
|
||||
assistant_id: header_int('X-Captain-Assistant-Id'),
|
||||
inbox_id: header_int('X-Captain-Inbox-Id')
|
||||
}.compact
|
||||
end
|
||||
|
||||
def header_int(name)
|
||||
value = request.headers[name].to_s
|
||||
return nil if value.blank?
|
||||
|
||||
value.to_i
|
||||
end
|
||||
end
|
||||
@ -1,2 +0,0 @@
|
||||
module Api::V1::Accounts::LandingHostsHelper
|
||||
end
|
||||
@ -5,7 +5,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import AggressiveConversationBanner from './components/app/AggressiveConversationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@ -31,7 +30,6 @@ export default {
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
AggressiveConversationBanner,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
@ -136,7 +134,6 @@ export default {
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<AggressiveConversationBanner />
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class ContactMemoriesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('memories', { accountScoped: true });
|
||||
}
|
||||
|
||||
get url() {
|
||||
return `${this.baseUrl()}/contacts/${this.contactId}/memories`;
|
||||
}
|
||||
|
||||
list(contactId) {
|
||||
this.contactId = contactId;
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
update(contactId, id, payload) {
|
||||
this.contactId = contactId;
|
||||
return axios.patch(`${this.url}/${id}`, payload);
|
||||
}
|
||||
|
||||
destroy(contactId, id) {
|
||||
this.contactId = contactId;
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
forgetAll(contactId) {
|
||||
this.contactId = contactId;
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactMemoriesAPI();
|
||||
@ -1,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainFunnel extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/reports/funnel', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(periodDays = 30) {
|
||||
return axios.get(this.url, { params: { period_days: periodDays } });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainFunnel();
|
||||
@ -1,38 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class HermesBuilder extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/hermes_builder', { accountScoped: true });
|
||||
}
|
||||
|
||||
fetchMessages() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
sendMessage(text) {
|
||||
return axios.post(this.url, { text });
|
||||
}
|
||||
|
||||
start() {
|
||||
return axios.post(`${this.url}/start`);
|
||||
}
|
||||
|
||||
reset() {
|
||||
return axios.delete(`${this.url}/reset`);
|
||||
}
|
||||
|
||||
fetchAssistants() {
|
||||
return axios.get(`${this.url}/assistants`);
|
||||
}
|
||||
|
||||
validate(slug) {
|
||||
return axios.get(`${this.url}/validate`, { params: { slug } });
|
||||
}
|
||||
|
||||
repair(slug, repairId) {
|
||||
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
|
||||
}
|
||||
}
|
||||
|
||||
export default new HermesBuilder();
|
||||
@ -1,18 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainLifecycleConfig extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/lifecycle_config', { accountScoped: true });
|
||||
}
|
||||
|
||||
show() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.patch(this.url, { config: data });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainLifecycleConfig();
|
||||
@ -1,18 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainLifecycleDeliveries extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/lifecycle_deliveries', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainLifecycleDeliveries();
|
||||
@ -1,30 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainLifecycleRules extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/lifecycle_rules', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { rule: data });
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, { rule: data });
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainLifecycleRules();
|
||||
@ -21,26 +21,6 @@ class CaptainReportsAPI extends ApiClient {
|
||||
generateInsight(data) {
|
||||
return axios.post(`${this.url}/insights/generate`, data);
|
||||
}
|
||||
|
||||
getExecutive(params = {}) {
|
||||
return axios.get(`${this.url}/executive`, { params });
|
||||
}
|
||||
|
||||
drilldown(params = {}) {
|
||||
return axios.get(`${this.url}/executive/drilldown`, { params });
|
||||
}
|
||||
|
||||
deliverExecutive(params = {}) {
|
||||
return axios.post(`${this.url}/executive/deliver`, params);
|
||||
}
|
||||
|
||||
getRetention(params = {}) {
|
||||
return axios.get(`${this.url}/retention`, { params });
|
||||
}
|
||||
|
||||
getRetentionCohort(params = {}) {
|
||||
return axios.get(`${this.url}/retention/cohort`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainReportsAPI();
|
||||
|
||||
@ -10,10 +10,6 @@ class CaptainReservations extends ApiClient {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { reservation: data });
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
@ -25,18 +21,6 @@ class CaptainReservations extends ApiClient {
|
||||
pix(id) {
|
||||
return axios.get(`${this.url}/${id}/pix`);
|
||||
}
|
||||
|
||||
cancel(id, reason = '') {
|
||||
return axios.post(`${this.url}/${id}/cancel`, { reason });
|
||||
}
|
||||
|
||||
markAsPaid(id, note = '') {
|
||||
return axios.post(`${this.url}/${id}/mark_as_paid`, { note });
|
||||
}
|
||||
|
||||
regeneratePix(id) {
|
||||
return axios.post(`${this.url}/${id}/regenerate_pix`, {});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainReservations();
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainRoleta extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/roleta', { accountScoped: true });
|
||||
}
|
||||
|
||||
pending(params = {}) {
|
||||
return axios.get(`${this.url}/pending`, { params });
|
||||
}
|
||||
|
||||
redeem(code, notes = '') {
|
||||
return axios.post(`${this.url}/redeem`, { code, notes });
|
||||
}
|
||||
|
||||
weeklyReport(periodDays = 7) {
|
||||
return axios.get(`${this.url}/weekly_report`, {
|
||||
params: { period_days: periodDays },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainRoleta();
|
||||
@ -1,4 +1,3 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../api/ApiClient';
|
||||
|
||||
class CaptainUnitsAPI extends ApiClient {
|
||||
@ -25,12 +24,6 @@ class CaptainUnitsAPI extends ApiClient {
|
||||
deleteUnit(id) {
|
||||
return this.delete(id);
|
||||
}
|
||||
|
||||
updateConcierge(id, payload) {
|
||||
return axios.patch(`${this.url}/${id}/concierge`, {
|
||||
captain_unit: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainUnitsAPI();
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
// API client para LandingHosts da caixa de entrada
|
||||
/* global axios */
|
||||
|
||||
export default {
|
||||
getHosts(accountId, inboxId) {
|
||||
return axios.get(
|
||||
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`
|
||||
);
|
||||
},
|
||||
createHost(accountId, inboxId, data) {
|
||||
return axios.post(
|
||||
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`,
|
||||
{ landing_host: data }
|
||||
);
|
||||
},
|
||||
updateHost(accountId, inboxId, id, data) {
|
||||
return axios.patch(
|
||||
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`,
|
||||
{ landing_host: data }
|
||||
);
|
||||
},
|
||||
deleteHost(accountId, inboxId, id) {
|
||||
return axios.delete(
|
||||
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -91,57 +91,23 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getBotMetrics({ from, to, inboxId } = {}) {
|
||||
getBotMetrics({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/bot_metrics`, {
|
||||
params: { since: from, until: to, inbox_id: inboxId },
|
||||
params: { since: from, until: to },
|
||||
});
|
||||
}
|
||||
|
||||
getBotSummary({ from, to, groupBy, businessHours, type, id } = {}) {
|
||||
getBotSummary({ from, to, groupBy, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/bot_summary`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
type: type || 'account',
|
||||
id,
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) {
|
||||
return axios.get(`${this.url}/inbox_leads_summary`, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
since: from,
|
||||
until: to,
|
||||
group_by: groupBy,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getConversionFunnel({ inboxId, from, to } = {}) {
|
||||
return axios.get(`${this.url}/conversion_funnel`, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
since: from,
|
||||
until: to,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getInboxBenchmarking({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/inbox_benchmarking`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
||||
@ -21,7 +21,6 @@ const state = reactive({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
trigger_keywords: '',
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
@ -56,7 +55,6 @@ const resetState = () => {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
trigger_keywords: '',
|
||||
instruction: '',
|
||||
});
|
||||
};
|
||||
@ -121,24 +119,6 @@ const onClickCancel = () => {
|
||||
:message-type="descriptionError ? 'error' : 'info'"
|
||||
show-character-count
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.trigger_keywords"
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:message="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.HELP')
|
||||
"
|
||||
message-type="info"
|
||||
rows="8"
|
||||
/>
|
||||
<Editor
|
||||
v-model="state.instruction"
|
||||
:label="
|
||||
|
||||
@ -26,10 +26,6 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
engine: {
|
||||
type: String,
|
||||
default: 'captain_interno',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
@ -80,27 +76,11 @@ const handleAction = ({ action, value }) => {
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</h6>
|
||||
<span
|
||||
v-if="engine === 'hermes'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||
</span>
|
||||
</div>
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</h6>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
|
||||
@ -10,7 +10,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@ -72,7 +71,6 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
@ -144,7 +142,7 @@ const handleDocumentableClick = () => {
|
||||
<div v-if="!compact && showMenu" class="flex items-center gap-2">
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="responseManagePermissions"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@ -170,7 +168,7 @@ const handleDocumentableClick = () => {
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="responseManagePermissions">
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
|
||||
@ -26,10 +26,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
triggerKeywords: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
instruction: {
|
||||
type: String,
|
||||
required: true,
|
||||
@ -62,7 +58,6 @@ const state = reactive({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
trigger_keywords: '',
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
@ -79,7 +74,6 @@ const startEdit = () => {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
trigger_keywords: props.triggerKeywords || '',
|
||||
instruction: props.instruction,
|
||||
tools: props.tools,
|
||||
});
|
||||
@ -229,22 +223,6 @@ const renderInstruction = instruction => () =>
|
||||
:message-type="descriptionError ? 'error' : 'info'"
|
||||
show-character-count
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.trigger_keywords"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:message="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.HELP')
|
||||
"
|
||||
message-type="info"
|
||||
rows="8"
|
||||
/>
|
||||
<Editor
|
||||
v-model="state.instruction"
|
||||
:label="
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
@ -12,52 +12,21 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
// Delimitador que separa o prompt base do sistema das instruções do assistente.
|
||||
// Este comentário fica na string salva no banco, mas é invisível para o usuário na UI.
|
||||
const SECTION_DELIMITER = '\n# ---SECAO-ASSISTENTE---\n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const systemText = ref('');
|
||||
const assistantText = ref('');
|
||||
const originalSystem = ref('');
|
||||
const originalAssistant = ref('');
|
||||
// true quando o prompt salvo não tem delimitador — avisa o usuário antes de salvar
|
||||
const missingDelimiter = ref(false);
|
||||
|
||||
function splitPrompt(fullText) {
|
||||
const idx = fullText.indexOf(SECTION_DELIMITER);
|
||||
if (idx === -1) {
|
||||
missingDelimiter.value = true;
|
||||
return { system: fullText, assistant: '' };
|
||||
}
|
||||
missingDelimiter.value = false;
|
||||
return {
|
||||
system: fullText.substring(0, idx),
|
||||
assistant: fullText.substring(idx + SECTION_DELIMITER.length),
|
||||
};
|
||||
}
|
||||
|
||||
function joinPrompt() {
|
||||
return systemText.value + SECTION_DELIMITER + assistantText.value;
|
||||
}
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
systemText.value !== originalSystem.value ||
|
||||
assistantText.value !== originalAssistant.value
|
||||
);
|
||||
const promptText = ref('');
|
||||
const originalText = ref('');
|
||||
const isDirty = ref(false);
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
const fullText =
|
||||
// Pré-popula com o prompt customizado salvo, ou com o .liquid padrão como ponto de partida
|
||||
const initialValue =
|
||||
assistant.orchestrator_prompt ||
|
||||
assistant.default_orchestrator_prompt ||
|
||||
'';
|
||||
const { system, assistant: assistantPart } = splitPrompt(fullText);
|
||||
systemText.value = system;
|
||||
assistantText.value = assistantPart;
|
||||
originalSystem.value = system;
|
||||
originalAssistant.value = assistantPart;
|
||||
promptText.value = initialValue;
|
||||
originalText.value = initialValue;
|
||||
isDirty.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
@ -68,30 +37,33 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(promptText, newVal => {
|
||||
isDirty.value = newVal !== originalText.value;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const full = joinPrompt();
|
||||
if (!full.trim()) {
|
||||
if (!promptText.value.trim()) {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.VALIDATION_ERROR'));
|
||||
return;
|
||||
}
|
||||
emit('submit', { orchestrator_prompt: full });
|
||||
originalSystem.value = systemText.value;
|
||||
originalAssistant.value = assistantText.value;
|
||||
emit('submit', { orchestrator_prompt: promptText.value });
|
||||
originalText.value = promptText.value;
|
||||
isDirty.value = false;
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Envia null para limpar o banco e voltar ao .liquid padrão
|
||||
emit('submit', { orchestrator_prompt: null });
|
||||
const defaultFull = props.assistant?.default_orchestrator_prompt || '';
|
||||
const { system, assistant: assistantPart } = splitPrompt(defaultFull);
|
||||
systemText.value = system;
|
||||
assistantText.value = assistantPart;
|
||||
originalSystem.value = system;
|
||||
originalAssistant.value = assistantPart;
|
||||
// Restaura a textarea para mostrar o conteúdo padrão novamente
|
||||
const defaultPrompt = props.assistant?.default_orchestrator_prompt || '';
|
||||
promptText.value = defaultPrompt;
|
||||
originalText.value = defaultPrompt;
|
||||
isDirty.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-full">
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<!-- Aviso de risco -->
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 w-full"
|
||||
@ -102,74 +74,23 @@ const handleReset = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Aviso: prompt sem delimitador (versão antiga) -->
|
||||
<div
|
||||
v-if="missingDelimiter"
|
||||
class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 border border-blue-200 text-blue-800 w-full"
|
||||
>
|
||||
<span class="i-lucide-info mt-0.5 shrink-0 text-blue-500" />
|
||||
<p class="text-sm leading-relaxed">
|
||||
{{ t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_PREFIX') }}
|
||||
<strong>{{
|
||||
t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_BUTTON')
|
||||
}}</strong>
|
||||
{{ t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_SUFFIX') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Seção 1: Prompt Base do Sistema -->
|
||||
<!-- Textarea do prompt -->
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-shield text-n-slate-10 text-sm" />
|
||||
<div class="flex flex-col">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_LABEL') }}
|
||||
</label>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.LABEL') }}
|
||||
</label>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="systemText"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_PLACEHOLDER')
|
||||
"
|
||||
class="w-full min-h-[500px] rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divisor visual -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 border-t border-dashed border-n-weak" />
|
||||
<span class="text-xs text-n-slate-9 whitespace-nowrap">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DIVIDER_LABEL') }}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-dashed border-n-weak" />
|
||||
</div>
|
||||
|
||||
<!-- Seção 2: Instruções do Assistente -->
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-pencil text-n-brand text-sm" />
|
||||
<div class="flex flex-col">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_LABEL') }}
|
||||
</label>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{
|
||||
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_DESCRIPTION')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
v-model="promptText"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.PLACEHOLDER')"
|
||||
class="w-full min-h-[500px] rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="assistantText"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_PLACEHOLDER')
|
||||
"
|
||||
class="w-full min-h-[500px] rounded-lg border border-n-brand/30 bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
|
||||
@ -6,7 +6,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
@ -29,7 +28,6 @@ const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@ -58,7 +56,7 @@ const onClearFilters = () => {
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="responseManagePermissions"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
@ -27,22 +27,6 @@ const route = useRoute();
|
||||
const dialogRef = ref(null);
|
||||
const responseForm = ref(null);
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const assistantOptions = computed(() => {
|
||||
if (route.params.assistantId) return [];
|
||||
return assistants.value.map(assistant => ({
|
||||
label: assistant.name,
|
||||
value: assistant.id,
|
||||
}));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.params.assistantId && !assistants.value.length) {
|
||||
store.dispatch('captainAssistants/get');
|
||||
}
|
||||
});
|
||||
|
||||
const updateResponse = responseDetails =>
|
||||
store.dispatch('captainResponses/update', {
|
||||
id: props.selectedResponse.id,
|
||||
@ -56,17 +40,15 @@ const createResponse = responseDetails =>
|
||||
|
||||
const handleSubmit = async updatedResponse => {
|
||||
try {
|
||||
const assistantId =
|
||||
route.params.assistantId || updatedResponse.assistant_id;
|
||||
if (props.type === 'edit') {
|
||||
await updateResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: assistantId,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
} else {
|
||||
await createResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: assistantId,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
}
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
@ -102,7 +84,6 @@ defineExpose({ dialogRef });
|
||||
ref="responseForm"
|
||||
:mode="type"
|
||||
:response="selectedResponse"
|
||||
:assistants="assistantOptions"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
@ -2,13 +2,12 @@
|
||||
import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, maxLength } from '@vuelidate/validators';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@ -20,10 +19,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
assistants: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
@ -36,29 +31,14 @@ const formState = {
|
||||
const initialState = {
|
||||
question: '',
|
||||
answer: '',
|
||||
assistant_id: '',
|
||||
};
|
||||
|
||||
const QUESTION_MAX_LENGTH = 255;
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const validationRules = computed(() => {
|
||||
const rules = {
|
||||
question: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
maxLength: maxLength(QUESTION_MAX_LENGTH),
|
||||
},
|
||||
answer: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
if (props.assistants && props.assistants.length > 0) {
|
||||
rules.assistant_id = { required };
|
||||
}
|
||||
|
||||
return rules;
|
||||
});
|
||||
const validationRules = {
|
||||
question: { required, minLength: minLength(1) },
|
||||
answer: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
@ -73,12 +53,6 @@ const getErrorMessage = (field, errorKey) => {
|
||||
const formErrors = computed(() => ({
|
||||
question: getErrorMessage('question', 'QUESTION'),
|
||||
answer: getErrorMessage('answer', 'ANSWER'),
|
||||
assistant_id: v$.value.assistant_id?.$error
|
||||
? t(
|
||||
'CAPTAIN.RESPONSES.FORM.ASSISTANT.ERROR',
|
||||
'Por favor, selecione um assistente.'
|
||||
)
|
||||
: '',
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
@ -86,7 +60,6 @@ const handleCancel = () => emit('cancel');
|
||||
const prepareDocumentDetails = () => ({
|
||||
question: state.question,
|
||||
answer: state.answer,
|
||||
...(state.assistant_id ? { assistant_id: state.assistant_id } : {}),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -101,19 +74,18 @@ const handleSubmit = async () => {
|
||||
const updateStateFromResponse = response => {
|
||||
if (!response) return;
|
||||
|
||||
const { question, answer, assistant_id } = response;
|
||||
const { question, answer } = response;
|
||||
|
||||
Object.assign(state, {
|
||||
question,
|
||||
answer,
|
||||
assistant_id: assistant_id || '',
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.response,
|
||||
newResponse => {
|
||||
if (newResponse) {
|
||||
if (props.mode === 'edit' && newResponse) {
|
||||
updateStateFromResponse(newResponse);
|
||||
}
|
||||
},
|
||||
@ -129,7 +101,6 @@ watch(
|
||||
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
|
||||
:message="formErrors.question"
|
||||
:message-type="formErrors.question ? 'error' : 'info'"
|
||||
:maxlength="QUESTION_MAX_LENGTH"
|
||||
/>
|
||||
<Editor
|
||||
v-model="state.answer"
|
||||
@ -139,27 +110,7 @@ watch(
|
||||
:max-length="10000"
|
||||
:message-type="formErrors.answer ? 'error' : 'info'"
|
||||
/>
|
||||
<div
|
||||
v-if="assistants && assistants.length > 0"
|
||||
class="flex flex-col w-full gap-2"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-11">
|
||||
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL', 'Assistente') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.assistant_id"
|
||||
:options="assistants"
|
||||
:has-error="!!formErrors.assistant_id"
|
||||
:message="formErrors.assistant_id"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER',
|
||||
'Por favor selecione o Assistente'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-3 mt-2">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
|
||||
@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createAssistant']);
|
||||
|
||||
@ -106,16 +105,14 @@ const openCreateAssistantDialog = () => {
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
||||
</p>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</Policy>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
@ -133,20 +130,6 @@ const openCreateAssistantDialog = () => {
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ assistant.name || '' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="assistant.engine === 'hermes'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||
</span>
|
||||
<Avatar
|
||||
v-if="assistant"
|
||||
:name="assistant.name"
|
||||
|
||||
@ -199,61 +199,6 @@ export function useContactFilterContext() {
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
// --- Retenção / recorrência ---
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.IS_RECURRING,
|
||||
value: CONTACT_ATTRIBUTES.IS_RECURRING,
|
||||
attributeName: 'Cliente recorrente',
|
||||
label: 'Cliente recorrente',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{ id: 'true', name: 'Sim' },
|
||||
{ id: 'false', name: 'Não' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
|
||||
value: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
|
||||
attributeName: 'Última interação',
|
||||
label: 'Última interação',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
|
||||
value: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
|
||||
attributeName: 'Dias sem interagir',
|
||||
label: 'Dias sem interagir',
|
||||
inputType: 'plainText',
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
|
||||
value: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
|
||||
attributeName: 'Nº de interações',
|
||||
label: 'Nº de interações',
|
||||
inputType: 'plainText',
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
|
||||
value: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
|
||||
attributeName: 'Reservas pagas',
|
||||
label: 'Reservas pagas',
|
||||
inputType: 'plainText',
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
|
||||
@ -28,12 +28,6 @@ export const CONTACT_ATTRIBUTES = {
|
||||
REFERER: 'referer',
|
||||
BLOCKED: 'blocked',
|
||||
LABELS: 'labels',
|
||||
// Retenção / recorrência (stats desnormalizados em contacts)
|
||||
IS_RECURRING: 'is_recurring',
|
||||
LAST_INTERACTION_AT: 'last_interaction_at',
|
||||
DAYS_SINCE_LAST_INTERACTION: 'days_since_last_interaction',
|
||||
INTERACTIONS_COUNT: 'interactions_count',
|
||||
RESERVATIONS_PAID_COUNT: 'reservations_paid_count',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -379,7 +379,7 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
(hasText || hasAttachments) &&
|
||||
!isFailedOrProcessing &&
|
||||
!isMessageDeleted.value,
|
||||
cannedResponse: !isMessageDeleted.value,
|
||||
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
|
||||
copyLink: !isFailedOrProcessing,
|
||||
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
|
||||
replyTo:
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@ -19,11 +20,19 @@ const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const hasError = ref(false);
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'image',
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment.value;
|
||||
try {
|
||||
@ -53,13 +62,12 @@ const handleImageError = () => {
|
||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
class="skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, getCurrentInstance } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
useTemplateRef,
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@ -23,7 +30,9 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const hasError = ref(false);
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'audio',
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
@ -50,9 +59,11 @@ const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
const handleAudioError = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
onMounted(() => {
|
||||
if (attachment.dataUrl) {
|
||||
loadWithRetry(attachment.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global audio play events and pause if it's not this audio
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
@ -132,7 +143,7 @@ const downloadAudio = async () => {
|
||||
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template v-else-if="isLoaded">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
@ -141,7 +152,6 @@ const downloadAudio = async () => {
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
@error="handleAudioError"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
|
||||
@ -402,40 +402,12 @@ const menuItems = computed(() => {
|
||||
],
|
||||
to: accountScopedRoute('captain_settings_gallery'),
|
||||
},
|
||||
{
|
||||
name: 'Lifecycle',
|
||||
label: t('SIDEBAR.CAPTAIN_LIFECYCLE'),
|
||||
activeOn: [
|
||||
'captain_lifecycle_rules',
|
||||
'captain_lifecycle_settings',
|
||||
'captain_lifecycle_history',
|
||||
],
|
||||
to: accountScopedRoute('captain_lifecycle_rules'),
|
||||
},
|
||||
{
|
||||
name: 'Reservations',
|
||||
label: t('SIDEBAR.CAPTAIN_RESERVATIONS'),
|
||||
activeOn: ['captain_reservations_index'],
|
||||
to: accountScopedRoute('captain_reservations_index'),
|
||||
},
|
||||
{
|
||||
name: 'Roleta',
|
||||
label: t('SIDEBAR.CAPTAIN_ROLETA'),
|
||||
activeOn: ['captain_roleta_index'],
|
||||
to: accountScopedRoute('captain_roleta_index'),
|
||||
},
|
||||
{
|
||||
name: 'HermesBuilder',
|
||||
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
|
||||
activeOn: ['captain_hermes_builder_index'],
|
||||
to: accountScopedRoute('captain_hermes_builder_index'),
|
||||
},
|
||||
{
|
||||
name: 'Funnel',
|
||||
label: t('SIDEBAR.CAPTAIN_FUNNEL'),
|
||||
activeOn: ['captain_funnel_index'],
|
||||
to: accountScopedRoute('captain_funnel_index'),
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
label: t('SIDEBAR.CAPTAIN_REPORTS'),
|
||||
@ -556,11 +528,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Directory Dashboard',
|
||||
label: t('SIDEBAR.REPORTS_DIRECTORY_DASHBOARD'),
|
||||
to: accountScopedRoute('directory_dashboard_reports'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,340 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import aggressiveAlert from 'dashboard/helper/aggressiveAlert';
|
||||
import inactivityAlertTracker from 'dashboard/helper/inactivityAlertTracker';
|
||||
|
||||
export default {
|
||||
name: 'AggressiveConversationBanner',
|
||||
data() {
|
||||
return {
|
||||
alerts: [],
|
||||
maxLevel: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
allConversations: 'getAllConversations',
|
||||
currentUser: 'getCurrentUser',
|
||||
}),
|
||||
allowedInboxIds() {
|
||||
// null → sem filtro (todas); array → só essas.
|
||||
const raw =
|
||||
this.currentUser &&
|
||||
this.currentUser.ui_settings &&
|
||||
this.currentUser.ui_settings.aggressive_alert_inbox_ids;
|
||||
if (raw == null) return null;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
return raw.map(id => Number(id));
|
||||
},
|
||||
hasAlerts() {
|
||||
return this.alerts.length > 0;
|
||||
},
|
||||
bannerClass() {
|
||||
return [
|
||||
'aggressive-banner',
|
||||
this.maxLevel ? `aggressive-banner--${this.maxLevel}` : '',
|
||||
];
|
||||
},
|
||||
bannerHeadline() {
|
||||
const count = this.alerts.length;
|
||||
if (count === 1) {
|
||||
const a = this.alerts[0];
|
||||
if (a.kind === 'reopened') {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_REOPENED',
|
||||
'Conversa reaberta — responda agora'
|
||||
);
|
||||
}
|
||||
// inactivity — mostra tempo
|
||||
if (a.minutes >= 28) {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_28',
|
||||
{ minutes: a.minutes },
|
||||
`🚨 ${a.minutes} MIN SEM RESPOSTA — conversa fecha em breve`
|
||||
);
|
||||
}
|
||||
if (a.minutes >= 15) {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_15',
|
||||
{ minutes: a.minutes },
|
||||
`⚠️ ${a.minutes} MIN SEM RESPOSTA`
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_5',
|
||||
{ minutes: a.minutes },
|
||||
`⏰ ${a.minutes} min sem resposta`
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_MULTIPLE',
|
||||
{ count },
|
||||
`🚨 ${count} conversas aguardando resposta`
|
||||
);
|
||||
},
|
||||
explanation() {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.EXPLANATION',
|
||||
'Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente.'
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Rehidrata o tracker de inatividade toda vez que a lista de conversas
|
||||
// muda (inclusive no boot). Dessa forma, conversas que já estão em
|
||||
// 'open' com o cliente esperando resposta entram no tracker mesmo
|
||||
// quando o usuário só abriu a aba sem receber mensagem ao vivo.
|
||||
allConversations: {
|
||||
handler(conversations) {
|
||||
const allowed = this.allowedInboxIds;
|
||||
const filtered =
|
||||
allowed === null
|
||||
? conversations
|
||||
: (conversations || []).filter(c =>
|
||||
allowed.includes(Number(c && c.inbox_id))
|
||||
);
|
||||
inactivityAlertTracker.hydrateFromConversations(filtered);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
|
||||
// Rehidrata se alertas foram disparados antes do componente montar
|
||||
this.refreshAlerts();
|
||||
},
|
||||
beforeUnmount() {
|
||||
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
|
||||
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
|
||||
},
|
||||
methods: {
|
||||
refreshAlerts() {
|
||||
this.alerts = aggressiveAlert.getActiveConversations();
|
||||
this.maxLevel = aggressiveAlert.getMaxLevel();
|
||||
},
|
||||
openConversation(alert) {
|
||||
// Clica no item → abre conversa E esconde o alerta dela (mas se
|
||||
// não responder, volta a aparecer no próximo threshold).
|
||||
// Param tem que ser `conversation_id` (snake_case, como
|
||||
// declarado no path da rota); camelCase faz Vue Router não casar
|
||||
// e cair em "selecione uma conversa".
|
||||
aggressiveAlert.dismiss(alert.id);
|
||||
if (!this.currentAccountId) return;
|
||||
this.$router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: this.currentAccountId,
|
||||
conversation_id: alert.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
dismissOne(alert) {
|
||||
aggressiveAlert.dismiss(alert.id);
|
||||
},
|
||||
alertItemClass(alert) {
|
||||
return [
|
||||
'aggressive-banner__item',
|
||||
`aggressive-banner__item--${alert.level}`,
|
||||
];
|
||||
},
|
||||
alertContextLabel(alert) {
|
||||
if (alert.kind === 'reopened') {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.KIND_REOPENED',
|
||||
'reabriu'
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.KIND_WAITING',
|
||||
{ minutes: alert.minutes || '?' },
|
||||
`${alert.minutes || '?'} min sem resposta`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasAlerts" :class="bannerClass" role="alert" aria-live="assertive">
|
||||
<div class="aggressive-banner__headline">
|
||||
{{ bannerHeadline }}
|
||||
</div>
|
||||
<div class="aggressive-banner__explanation">
|
||||
{{ explanation }}
|
||||
</div>
|
||||
<ul class="aggressive-banner__list">
|
||||
<li
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
:class="alertItemClass(alert)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__open"
|
||||
@click="openConversation(alert)"
|
||||
>
|
||||
<span class="aggressive-banner__contact">{{
|
||||
alert.contactName || '—'
|
||||
}}</span>
|
||||
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
|
||||
· {{ alert.inboxName }}
|
||||
</span>
|
||||
<span class="aggressive-banner__context">
|
||||
· {{ alertContextLabel(alert) }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__close"
|
||||
:aria-label="
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE', 'Esconder')
|
||||
"
|
||||
:title="
|
||||
$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE_TITLE',
|
||||
'Esconde temporariamente — volta se não responder'
|
||||
)
|
||||
"
|
||||
@click="dismissOne(alert)"
|
||||
>
|
||||
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ICON', '×') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes aggressive-pulse-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #eab308;
|
||||
}
|
||||
50% {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-orange {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #c2410c;
|
||||
}
|
||||
50% {
|
||||
background-color: #f97316;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-red {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #991b1b;
|
||||
}
|
||||
50% {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.aggressive-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
color: #ffffff;
|
||||
padding: 14px 20px;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aggressive-banner--yellow {
|
||||
background-color: #eab308;
|
||||
color: #1f2937;
|
||||
}
|
||||
.aggressive-banner--orange {
|
||||
background-color: #c2410c;
|
||||
animation: aggressive-pulse-orange 1.4s ease-in-out infinite;
|
||||
}
|
||||
.aggressive-banner--red {
|
||||
background-color: #991b1b;
|
||||
animation: aggressive-pulse-red 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.aggressive-banner__headline {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.aggressive-banner__explanation {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
opacity: 0.92;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aggressive-banner__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aggressive-banner__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aggressive-banner__item--yellow {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.aggressive-banner__open {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aggressive-banner__open:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.aggressive-banner__contact {
|
||||
font-weight: 800;
|
||||
}
|
||||
.aggressive-banner__inbox,
|
||||
.aggressive-banner__context {
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aggressive-banner__close {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
.aggressive-banner__close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@ -366,9 +366,6 @@ export default {
|
||||
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
scrollToBottom() {
|
||||
if (!this.conversationPanel) {
|
||||
return;
|
||||
}
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
|
||||
@ -1,130 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const contact = computed(() =>
|
||||
store.getters['contacts/getContact'](props.contactId)
|
||||
);
|
||||
|
||||
const summary = computed(() => {
|
||||
if (!contact.value) return null;
|
||||
return {
|
||||
interactions: contact.value.interactions_count ?? 0,
|
||||
oneShots: contact.value.one_shot_consultations_count ?? 0,
|
||||
pixGenerated: contact.value.pix_generated_count ?? 0,
|
||||
reservationsPaid: contact.value.reservations_paid_count ?? 0,
|
||||
lastInteractionAt: contact.value.last_interaction_at ?? null,
|
||||
daysSince: contact.value.days_since_last_interaction ?? null,
|
||||
isRecurring: contact.value.is_recurring ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
const status = computed(() => {
|
||||
const s = summary.value;
|
||||
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
|
||||
if (!s || s.interactions === 0)
|
||||
return { label: t(`${B}.STATUS_FIRST`), tone: 'slate' };
|
||||
if (s.daysSince !== null && s.daysSince > 180)
|
||||
return { label: t(`${B}.STATUS_INACTIVE`), tone: 'rose' };
|
||||
if (s.daysSince !== null && s.daysSince > 90)
|
||||
return { label: t(`${B}.STATUS_AT_RISK`), tone: 'orange' };
|
||||
if (s.daysSince !== null && s.daysSince > 30)
|
||||
return { label: t(`${B}.STATUS_SLEEPING`), tone: 'amber' };
|
||||
if (s.isRecurring)
|
||||
return { label: t(`${B}.STATUS_RECURRING`), tone: 'emerald' };
|
||||
return { label: t(`${B}.STATUS_ACTIVE`), tone: 'sky' };
|
||||
});
|
||||
|
||||
const toneClass = {
|
||||
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||
emerald:
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
sky: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
|
||||
orange:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
|
||||
rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
function formatDaysSince(days) {
|
||||
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
|
||||
if (days === null || days === undefined) return '';
|
||||
if (days === 0) return t(`${B}.DAYS_TODAY`);
|
||||
if (days === 1) return t(`${B}.DAYS_YESTERDAY`);
|
||||
if (days < 30) return t(`${B}.DAYS_RECENT`, { days });
|
||||
if (days < 60) return t(`${B}.DAYS_ONE_MONTH`);
|
||||
if (days < 365)
|
||||
return t(`${B}.DAYS_MONTHS`, { months: Math.round(days / 30) });
|
||||
return t(`${B}.DAYS_YEARS`, { years: Math.round(days / 365) });
|
||||
}
|
||||
|
||||
const interactionsLabel = computed(() => {
|
||||
const s = summary.value;
|
||||
if (!s) return '';
|
||||
const parts = t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_LABEL').split(
|
||||
' | '
|
||||
);
|
||||
return s.interactions === 1 ? parts[0] : parts[1] || parts[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="summary"
|
||||
class="mb-3 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-2.5 dark:border-slate-700 dark:bg-slate-800/40"
|
||||
>
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span
|
||||
:class="toneClass[status.tone]"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{{ status.label }}
|
||||
</span>
|
||||
<span v-if="summary.lastInteractionAt" class="text-[10px] text-slate-500">
|
||||
{{
|
||||
$t('CAPTAIN_REPORTS.RETENTION.BADGE.LAST_INTERACTION', {
|
||||
days: formatDaysSince(summary.daysSince),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<span :title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_TITLE')">
|
||||
<strong class="text-slate-900 dark:text-slate-100">{{
|
||||
summary.interactions
|
||||
}}</strong>
|
||||
{{ interactionsLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="summary.oneShots > 0"
|
||||
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_TITLE')"
|
||||
>
|
||||
<strong class="text-slate-900 dark:text-slate-100">{{
|
||||
summary.oneShots
|
||||
}}</strong>
|
||||
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="summary.pixGenerated > 0"
|
||||
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_TITLE')"
|
||||
>
|
||||
<strong class="text-slate-900 dark:text-slate-100">{{
|
||||
summary.reservationsPaid
|
||||
}}</strong
|
||||
>/{{ summary.pixGenerated }}
|
||||
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
@ -9,7 +9,6 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'reservation_summary' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'contact_notes' },
|
||||
{ name: 'contact_memories' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear_issues' },
|
||||
|
||||
@ -110,16 +110,12 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
try {
|
||||
const url = new URL(dataUrl, window.location.origin);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
const connector = dataUrl.includes('?') ? '&' : '?';
|
||||
return `${dataUrl}${connector}t=${Date.now()}`;
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const getHostNameFromURL = url => {
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import AuthAPI from '../api/auth';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import aggressiveAlert from './aggressiveAlert';
|
||||
import inactivityAlertTracker from './inactivityAlertTracker';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
@ -109,127 +107,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
lastActivityAt,
|
||||
conversationId,
|
||||
});
|
||||
this.feedInactivityTracker(data);
|
||||
};
|
||||
|
||||
// Alimenta o tracker de inatividade:
|
||||
// - Cliente (Contact) mandou mensagem em conversa open → começa a contar
|
||||
// - Agente (User/AgentBot/Captain) mandou mensagem → limpa (agente respondeu)
|
||||
// - Status deixou de ser open → trata como "resolvido", limpa
|
||||
feedInactivityTracker = data => {
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
const {
|
||||
conversation_id: conversationId,
|
||||
message_type: messageType,
|
||||
sender_type: senderType,
|
||||
conversation,
|
||||
} = data;
|
||||
|
||||
// message_type: 0=incoming, 1=outgoing, 2=activity, 3=template
|
||||
// Activity = evento do sistema (status mudou, etc). Ignora.
|
||||
if (messageType === 2 || messageType === 'activity') return;
|
||||
|
||||
// Incoming (cliente) e conversa aberta → começa/renova tracker
|
||||
const isIncoming = messageType === 0 || messageType === 'incoming';
|
||||
const conversationStatus = conversation && conversation.status;
|
||||
if (isIncoming && conversationStatus === 'open') {
|
||||
const inboxId = conversation && conversation.inbox_id;
|
||||
if (!this.isInboxAllowedForUser(inboxId)) return;
|
||||
const contactName =
|
||||
conversation && conversation.meta && conversation.meta.sender
|
||||
? conversation.meta.sender.name
|
||||
: '';
|
||||
const inbox = this.app.$store.getters['inboxes/getInbox']
|
||||
? this.app.$store.getters['inboxes/getInbox'](inboxId)
|
||||
: null;
|
||||
const inboxName = inbox && inbox.name ? inbox.name : '';
|
||||
inactivityAlertTracker.onClientMessage({
|
||||
conversationId,
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Qualquer mensagem do agente/bot → limpa tracker
|
||||
if (senderType === 'User' || senderType === 'AgentBot') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
// Lê account.settings.aggressive_alert_enabled + user.ui_settings
|
||||
isAggressiveAlertEnabled = () => {
|
||||
const store = this.app.$store;
|
||||
const account = store.getters.getCurrentAccount;
|
||||
const user = store.getters.getCurrentUser;
|
||||
|
||||
// Default true se settings não vieram ainda (não bloqueia no boot).
|
||||
const accountEnabled =
|
||||
!account ||
|
||||
!account.settings ||
|
||||
account.settings.aggressive_alert_enabled !== false;
|
||||
const userEnabled =
|
||||
!user ||
|
||||
!user.ui_settings ||
|
||||
user.ui_settings.aggressive_alert_enabled !== false;
|
||||
|
||||
return accountEnabled && userEnabled;
|
||||
};
|
||||
|
||||
// Filtra alertas por inbox conforme a preferência do user.
|
||||
// ui_settings.aggressive_alert_inbox_ids:
|
||||
// - null/undefined → todas as inboxes (default, legado)
|
||||
// - [] (vazio) → nenhuma inbox (silenciou tudo)
|
||||
// - [1, 2, 3] → só essas inboxes
|
||||
isInboxAllowedForUser = inboxId => {
|
||||
if (inboxId == null) return true;
|
||||
const user = this.app.$store.getters.getCurrentUser;
|
||||
const allowed =
|
||||
user && user.ui_settings && user.ui_settings.aggressive_alert_inbox_ids;
|
||||
if (allowed == null) return true;
|
||||
if (!Array.isArray(allowed)) return true;
|
||||
// Inbox ids podem vir como number no evento e string no ui_settings.
|
||||
return allowed.some(id => Number(id) === Number(inboxId));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReload = () => window.location.reload();
|
||||
|
||||
onStatusChange = data => {
|
||||
this.maybeTriggerAggressiveAlert(data);
|
||||
// Se saiu de 'open' (resolvida/snoozada/pending), limpa qualquer alerta
|
||||
// pendente pra essa conversa.
|
||||
if (data && data.id && data.status && data.status !== 'open') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(data.id);
|
||||
}
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
// Dispara banner RED toda vez que a conversa transita pra 'open'.
|
||||
// Broadcast `conversation.status_changed` só chega em mudança real,
|
||||
// então confiar no evento é suficiente.
|
||||
maybeTriggerAggressiveAlert = data => {
|
||||
if (!data || data.status !== 'open') return;
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
|
||||
const store = this.app.$store;
|
||||
const contactName =
|
||||
data.meta && data.meta.sender ? data.meta.sender.name : '';
|
||||
const inbox = store.getters['inboxes/getInbox']
|
||||
? store.getters['inboxes/getInbox'](data.inbox_id)
|
||||
: null;
|
||||
const inboxName = inbox && inbox.name ? inbox.name : '';
|
||||
|
||||
aggressiveAlert.trigger({
|
||||
conversationId: data.id,
|
||||
level: 'red',
|
||||
kind: 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
};
|
||||
|
||||
onConversationUpdated = data => {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
|
||||
@ -1,303 +0,0 @@
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const ALERT_AUDIO_PATH = '/audio/dashboard/bell.mp3';
|
||||
const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
|
||||
const TITLE_FLASH_INTERVAL_MS = 1000;
|
||||
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
|
||||
|
||||
// Níveis de severidade — ordem numérica cresce com a urgência.
|
||||
export const LEVEL = {
|
||||
YELLOW: 'yellow',
|
||||
ORANGE: 'orange',
|
||||
RED: 'red',
|
||||
};
|
||||
|
||||
const LEVEL_SEVERITY = {
|
||||
[LEVEL.YELLOW]: 1,
|
||||
[LEVEL.ORANGE]: 2,
|
||||
[LEVEL.RED]: 3,
|
||||
};
|
||||
|
||||
const showOSNotification = (title, body) => {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new Notification(title, {
|
||||
body,
|
||||
tag: NOTIFICATION_TAG,
|
||||
requireInteraction: true,
|
||||
renotify: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// Safari iOS lança TypeError no construtor; banner visual + som cobrem.
|
||||
}
|
||||
};
|
||||
|
||||
const vibrateDevice = () => {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.vibrate === 'function'
|
||||
) {
|
||||
navigator.vibrate(VIBRATION_PATTERN);
|
||||
}
|
||||
};
|
||||
|
||||
class AggressiveAlertManager {
|
||||
constructor() {
|
||||
this.audio = null;
|
||||
this.titleInterval = null;
|
||||
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
|
||||
// Map<conversationId, { level, kind, contactName, inboxName, minutes, triggeredAt, temporarilyHidden }>
|
||||
this.activeConversations = new Map();
|
||||
}
|
||||
|
||||
ensureAudio() {
|
||||
if (this.audio) return;
|
||||
this.audio = new Audio(ALERT_AUDIO_PATH);
|
||||
}
|
||||
|
||||
// Som em loop infinito (usado pro nível RED — urgência máxima)
|
||||
playLoopSound() {
|
||||
this.ensureAudio();
|
||||
this.audio.loop = true;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {
|
||||
// Autoplay bloqueado pelo browser — banner visual permanece.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Som 1x (usado pro ORANGE — chama atenção mas não satura)
|
||||
playOnceSound() {
|
||||
// Se já está tocando em loop pra outro alerta, não interfere.
|
||||
if (this.hasLoopSound()) return;
|
||||
this.ensureAudio();
|
||||
this.audio.loop = false;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
hasLoopSound() {
|
||||
// Loop está ativo se algum alerta no map tem level === RED e não está hidden.
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry => entry.level === LEVEL.RED && !entry.temporarilyHidden
|
||||
);
|
||||
}
|
||||
|
||||
stopSound() {
|
||||
if (!this.audio) return;
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.loop = false;
|
||||
}
|
||||
|
||||
// O título pisca se existir pelo menos 1 alerta visível com level ORANGE ou RED.
|
||||
shouldFlashTitle() {
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry =>
|
||||
!entry.temporarilyHidden &&
|
||||
(entry.level === LEVEL.ORANGE || entry.level === LEVEL.RED)
|
||||
);
|
||||
}
|
||||
|
||||
countVisibleAlerts() {
|
||||
return Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
).length;
|
||||
}
|
||||
|
||||
updateTitleTick(toggle) {
|
||||
if (!this.shouldFlashTitle()) {
|
||||
document.title = this.originalTitle;
|
||||
return;
|
||||
}
|
||||
const count = this.countVisibleAlerts();
|
||||
document.title = toggle
|
||||
? `🚨 (${count}) CONVERSA ABERTA`
|
||||
: this.originalTitle;
|
||||
}
|
||||
|
||||
startTitleFlash() {
|
||||
if (this.titleInterval) return;
|
||||
if (!this.shouldFlashTitle()) return;
|
||||
let toggle = false;
|
||||
this.updateTitleTick(true);
|
||||
this.titleInterval = setInterval(() => {
|
||||
toggle = !toggle;
|
||||
this.updateTitleTick(toggle);
|
||||
}, TITLE_FLASH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stopTitleFlash() {
|
||||
if (this.titleInterval) {
|
||||
clearInterval(this.titleInterval);
|
||||
this.titleInterval = null;
|
||||
}
|
||||
document.title = this.originalTitle;
|
||||
}
|
||||
|
||||
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
|
||||
refreshOutputs() {
|
||||
const hasLoop = this.hasLoopSound();
|
||||
const shouldFlash = this.shouldFlashTitle();
|
||||
|
||||
if (hasLoop) {
|
||||
this.playLoopSound();
|
||||
} else {
|
||||
this.stopSound();
|
||||
}
|
||||
|
||||
if (shouldFlash) {
|
||||
this.startTitleFlash();
|
||||
} else {
|
||||
this.stopTitleFlash();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara ou escala um alerta.
|
||||
* @param {Object} opts
|
||||
* @param {number|string} opts.conversationId
|
||||
* @param {string} opts.level - LEVEL.YELLOW | LEVEL.ORANGE | LEVEL.RED
|
||||
* @param {string} opts.kind - 'reopened' | 'inactivity'
|
||||
* @param {string} [opts.contactName]
|
||||
* @param {string} [opts.inboxName]
|
||||
* @param {number} [opts.minutes] - só pra inactivity (5/15/28)
|
||||
*/
|
||||
trigger({
|
||||
conversationId,
|
||||
level = LEVEL.RED,
|
||||
kind = 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
minutes,
|
||||
}) {
|
||||
if (!conversationId) return;
|
||||
const existing = this.activeConversations.get(conversationId);
|
||||
|
||||
// Escalada: se já existe e o novo level é MENOS severo, ignora.
|
||||
// Se for mais severo, atualiza (ex: yellow → orange, inactivity).
|
||||
if (existing) {
|
||||
const currentSev = LEVEL_SEVERITY[existing.level] || 0;
|
||||
const incomingSev = LEVEL_SEVERITY[level] || 0;
|
||||
// Se o alerta tá "escondido temporariamente" e chegou novo, desesconde.
|
||||
if (incomingSev >= currentSev || existing.temporarilyHidden) {
|
||||
this.activeConversations.set(conversationId, {
|
||||
...existing,
|
||||
level: incomingSev > currentSev ? level : existing.level,
|
||||
kind: incomingSev > currentSev ? kind : existing.kind,
|
||||
minutes: incomingSev > currentSev ? minutes : existing.minutes,
|
||||
contactName: contactName || existing.contactName,
|
||||
inboxName: inboxName || existing.inboxName,
|
||||
temporarilyHidden: false,
|
||||
triggeredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.activeConversations.set(conversationId, {
|
||||
level,
|
||||
kind,
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
minutes: minutes || null,
|
||||
triggeredAt: Date.now(),
|
||||
temporarilyHidden: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Som por nível
|
||||
if (level === LEVEL.RED) {
|
||||
this.playLoopSound();
|
||||
} else if (level === LEVEL.ORANGE) {
|
||||
this.playOnceSound();
|
||||
}
|
||||
// YELLOW: sem som
|
||||
|
||||
if (level === LEVEL.ORANGE || level === LEVEL.RED) {
|
||||
showOSNotification(
|
||||
'🚨 Conversa aguardando resposta',
|
||||
`${contactName || 'Cliente'} — ${inboxName || ''}`.trim()
|
||||
);
|
||||
vibrateDevice();
|
||||
}
|
||||
|
||||
this.startTitleFlash();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* × — dismiss temporário. Remove do visual mas mantém no map como "hidden".
|
||||
* Volta a aparecer se escalar (receber mais severo) ou receber nova mensagem.
|
||||
* Pra limpar de verdade, o agente tem que responder (então o tracker chama
|
||||
* dismissForReply).
|
||||
*/
|
||||
hide(conversationId) {
|
||||
const entry = this.activeConversations.get(conversationId);
|
||||
if (!entry) return;
|
||||
this.activeConversations.set(conversationId, {
|
||||
...entry,
|
||||
temporarilyHidden: true,
|
||||
});
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss definitivo — chamado quando o agente respondeu ou o tracker
|
||||
* detectou que o cliente não é mais o último a mandar.
|
||||
*/
|
||||
dismissForReply(conversationId) {
|
||||
if (!this.activeConversations.has(conversationId)) return;
|
||||
this.activeConversations.delete(conversationId);
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
// Mesmo que hide, mas pra API pública (botão × do banner)
|
||||
dismiss(conversationId) {
|
||||
this.hide(conversationId);
|
||||
}
|
||||
|
||||
dismissAll() {
|
||||
if (this.activeConversations.size === 0) return;
|
||||
this.activeConversations.clear();
|
||||
this.stopSound();
|
||||
this.stopTitleFlash();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, null);
|
||||
}
|
||||
|
||||
emitBusEvent(event, conversationId) {
|
||||
emitter.emit(event, {
|
||||
conversationId,
|
||||
total: this.countVisibleAlerts(),
|
||||
});
|
||||
}
|
||||
|
||||
getActiveConversations() {
|
||||
return Array.from(this.activeConversations.entries())
|
||||
.filter(([, data]) => !data.temporarilyHidden)
|
||||
.map(([id, data]) => ({ id, ...data }));
|
||||
}
|
||||
|
||||
// Level mais alto entre os alertas visíveis — o banner usa pra cor do wrapper.
|
||||
getMaxLevel() {
|
||||
const visible = Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
);
|
||||
if (visible.length === 0) return null;
|
||||
return visible.reduce((winner, entry) => {
|
||||
const sevWinner = LEVEL_SEVERITY[winner] || 0;
|
||||
const sevEntry = LEVEL_SEVERITY[entry.level] || 0;
|
||||
return sevEntry > sevWinner ? entry.level : winner;
|
||||
}, null);
|
||||
}
|
||||
}
|
||||
|
||||
const aggressiveAlert = new AggressiveAlertManager();
|
||||
|
||||
export default aggressiveAlert;
|
||||
@ -1,245 +0,0 @@
|
||||
import aggressiveAlert, { LEVEL } from './aggressiveAlert';
|
||||
|
||||
// Thresholds de inatividade. Cada um dispara UMA vez por conversa (enquanto
|
||||
// o cliente segue sendo o último a falar). Ordem: do menos urgente ao mais.
|
||||
const THRESHOLDS = [
|
||||
{ minutes: 5, level: LEVEL.YELLOW },
|
||||
{ minutes: 15, level: LEVEL.ORANGE },
|
||||
{ minutes: 28, level: LEVEL.RED },
|
||||
];
|
||||
|
||||
// Checa o estado dos alertas a cada 20s — granularidade suficiente pra
|
||||
// não perder threshold (a menor janela entre thresholds é 5min = 300s).
|
||||
const CHECK_INTERVAL_MS = 20_000;
|
||||
|
||||
// Logs opt-in. Ativar no DevTools: window.__AGGRESSIVE_DEBUG__ = true
|
||||
// Serve pra investigar porque o banner de inatividade não dispara numa
|
||||
// conversa específica sem ter que tornar logs permanentes em prod.
|
||||
const debug = (...args) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window.__AGGRESSIVE_DEBUG__
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[aggressive-alert]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
function findLastNonActivityMessage(conv) {
|
||||
// 1) Preferir o campo dedicado do payload da listagem — o serializer
|
||||
// já filtra atividades (`non_activity_messages`) antes de setar aqui.
|
||||
if (conv.last_non_activity_message) return conv.last_non_activity_message;
|
||||
// 2) Fallback pro array `messages` (só tem a última mensagem, e pode ser
|
||||
// uma activity — filtra pra garantir).
|
||||
if (conv.messages && conv.messages.length) {
|
||||
const nonActivity = conv.messages.filter(
|
||||
m => m && m.message_type !== 2 && m.message_type !== 'activity'
|
||||
);
|
||||
if (nonActivity.length) return nonActivity[nonActivity.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Chatwoot usa Unix timestamp (segundos) na maior parte dos endpoints e
|
||||
// ISO em alguns. Suporta os dois.
|
||||
function parseCreatedAt(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
return value < 10_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
class InactivityAlertTracker {
|
||||
constructor() {
|
||||
// Map<conversationId, { lastClientAt, firedMinutes: Set<number>, contactName, inboxName }>
|
||||
this.conversations = new Map();
|
||||
this.interval = null;
|
||||
this.enabledGetter = () => true; // injetado pelo actionCable com o store
|
||||
}
|
||||
|
||||
setEnabledGetter(fn) {
|
||||
this.enabledGetter = fn;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.interval) return;
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra ou atualiza que o CLIENTE mandou mensagem em uma conversa aberta.
|
||||
* Zera os thresholds se já existia (porque o relógio recomeça).
|
||||
*/
|
||||
onClientMessage({ conversationId, contactName, inboxName }) {
|
||||
if (!conversationId) return;
|
||||
this.conversations.set(conversationId, {
|
||||
lastClientAt: Date.now(),
|
||||
firedMinutes: new Set(),
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
});
|
||||
debug('onClientMessage', { conversationId, contactName, inboxName });
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa a conversa — agente respondeu ou cenário não mais aplicável.
|
||||
* Também dá dismiss no banner pra parar som.
|
||||
*/
|
||||
onAgentReplyOrResolved(conversationId) {
|
||||
if (!conversationId) return;
|
||||
if (this.conversations.has(conversationId)) {
|
||||
this.conversations.delete(conversationId);
|
||||
}
|
||||
aggressiveAlert.dismissForReply(conversationId);
|
||||
if (this.conversations.size === 0) this.stop();
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.enabledGetter()) {
|
||||
debug('tick skip: disabled');
|
||||
return;
|
||||
}
|
||||
if (this.conversations.size === 0) {
|
||||
debug('tick: empty map, stopping interval');
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
debug('tick', { size: this.conversations.size });
|
||||
Array.from(this.conversations.entries()).forEach(
|
||||
([conversationId, entry]) => {
|
||||
const elapsedMin = (now - entry.lastClientAt) / 60000;
|
||||
debug('tick entry', {
|
||||
conversationId,
|
||||
elapsedMin: elapsedMin.toFixed(2),
|
||||
fired: Array.from(entry.firedMinutes),
|
||||
});
|
||||
THRESHOLDS.forEach(t => {
|
||||
if (elapsedMin < t.minutes) return;
|
||||
if (entry.firedMinutes.has(t.minutes)) return;
|
||||
entry.firedMinutes.add(t.minutes);
|
||||
debug('THRESHOLD HIT', {
|
||||
conversationId,
|
||||
minutes: t.minutes,
|
||||
level: t.level,
|
||||
});
|
||||
aggressiveAlert.trigger({
|
||||
conversationId,
|
||||
level: t.level,
|
||||
kind: 'inactivity',
|
||||
contactName: entry.contactName,
|
||||
inboxName: entry.inboxName,
|
||||
minutes: t.minutes,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varre a lista de conversas do store e popula o tracker com aquelas
|
||||
* que estão em 'open' e tiveram o cliente como último remetente.
|
||||
* Usa o `created_at` da última msg como âncora de tempo (não Date.now()),
|
||||
* pra fechar o gap dos thresholds perdidos enquanto a aba estava fechada.
|
||||
*
|
||||
* Se a conversa já está no tracker com timestamp ≥ ao recém-lido, ignora
|
||||
* (mantém o estado dos firedMinutes — evita re-trigger em re-hidratação).
|
||||
*/
|
||||
hydrateFromConversations(conversations) {
|
||||
if (!this.enabledGetter()) {
|
||||
debug('hydrate skip: disabled');
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(conversations) || conversations.length === 0) {
|
||||
debug('hydrate skip: empty list');
|
||||
return;
|
||||
}
|
||||
debug('hydrate start', { total: conversations.length });
|
||||
|
||||
let hydrated = 0;
|
||||
let skippedNotOpen = 0;
|
||||
let skippedNoMsg = 0;
|
||||
let skippedAgentLast = 0;
|
||||
let skippedNoTs = 0;
|
||||
conversations.forEach(conv => {
|
||||
if (!conv || conv.status !== 'open') {
|
||||
skippedNotOpen += 1;
|
||||
return;
|
||||
}
|
||||
const lastMsg = findLastNonActivityMessage(conv);
|
||||
if (!lastMsg) {
|
||||
skippedNoMsg += 1;
|
||||
debug('hydrate skip (no last msg)', { id: conv.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const isClient =
|
||||
lastMsg.sender_type === 'Contact' ||
|
||||
lastMsg.message_type === 0 ||
|
||||
lastMsg.message_type === 'incoming';
|
||||
if (!isClient) {
|
||||
// Última msg foi do agente/bot — garante que não está no tracker
|
||||
if (this.conversations.has(conv.id)) {
|
||||
this.conversations.delete(conv.id);
|
||||
}
|
||||
skippedAgentLast += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastClientAt = parseCreatedAt(lastMsg.created_at);
|
||||
if (!lastClientAt) {
|
||||
skippedNoTs += 1;
|
||||
debug('hydrate skip (bad ts)', {
|
||||
id: conv.id,
|
||||
raw: lastMsg.created_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.conversations.get(conv.id);
|
||||
if (existing && existing.lastClientAt >= lastClientAt) return;
|
||||
|
||||
const contactName =
|
||||
(conv.meta && conv.meta.sender && conv.meta.sender.name) || '';
|
||||
const inboxName = (conv.inbox && conv.inbox.name) || '';
|
||||
|
||||
this.conversations.set(conv.id, {
|
||||
lastClientAt,
|
||||
firedMinutes: new Set(),
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
hydrated += 1;
|
||||
});
|
||||
|
||||
debug('hydrate done', {
|
||||
hydrated,
|
||||
skippedNotOpen,
|
||||
skippedNoMsg,
|
||||
skippedAgentLast,
|
||||
skippedNoTs,
|
||||
mapSize: this.conversations.size,
|
||||
});
|
||||
|
||||
if (hydrated > 0) {
|
||||
this.start();
|
||||
// Dispara imediatamente — se já passou de algum threshold, o tick
|
||||
// seguinte (20s) detectaria. Mas rodar aqui antecipa em até 20s.
|
||||
this.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inactivityAlertTracker = new InactivityAlertTracker();
|
||||
|
||||
export default inactivityAlertTracker;
|
||||
@ -94,13 +94,6 @@
|
||||
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
|
||||
"SUCCESS_MESSAGE": "Agent password reset successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"LABEL": "Aggressive alert — inboxes",
|
||||
"DESCRIPTION": "Choose which inboxes will trigger the reopened/inactivity banner for this agent.",
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"PICK_INBOXES": "Select inboxes",
|
||||
"NONE_WARNING": "No inbox selected — this agent will not see the aggressive alert."
|
||||
}
|
||||
},
|
||||
"SEARCH": {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"HEADLINE_REOPENED": "🚨 Conversation reopened — reply now",
|
||||
"HEADLINE_5": "⏰ {minutes} min without reply",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN WITHOUT REPLY — respond!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN WITHOUT REPLY — conversation will auto-close!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversations awaiting reply",
|
||||
"EXPLANATION": "This alert only clears when you REPLY to the conversation. Clicking × hides temporarily — it comes back if you do not reply.",
|
||||
"KIND_REOPENED": "just reopened",
|
||||
"KIND_WAITING": "{minutes} min without reply",
|
||||
"HIDE_ONE": "Hide",
|
||||
"HIDE_ONE_TITLE": "Hide temporarily — comes back if you do not reply",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
@ -2,25 +2,6 @@
|
||||
"CAPTAIN_RESERVATIONS": {
|
||||
"HEADER": "Reservations",
|
||||
"EMPTY": "No reservations found.",
|
||||
"CREATE_SUCCESS": "Reservation successfully created.",
|
||||
"CREATE_ERROR": "Failed to create reservation.",
|
||||
"NEW_RESERVATION_MODAL": {
|
||||
"TITLE": "New Reservation",
|
||||
"CONFIRM": "Create Reservation",
|
||||
"CANCEL": "Cancel",
|
||||
"FIELDS": {
|
||||
"CONTACT_ID": "Contact ID",
|
||||
"CONTACT_ID_PLACEHOLDER": "Enter the contact ID",
|
||||
"INBOX": "Inbox",
|
||||
"INBOX_PLACEHOLDER": "Select an inbox",
|
||||
"STATUS": "Reservation Status",
|
||||
"STATUS_PLACEHOLDER": "Select status",
|
||||
"SUITE_IDENTIFIER": "Suite Identifier (Ex: 101, Master Suite)",
|
||||
"CHECK_IN": "Check-in",
|
||||
"CHECK_OUT": "Check-out",
|
||||
"TOTAL_AMOUNT": "Total Amount"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"LIST": "List",
|
||||
"KANBAN": "Kanban",
|
||||
@ -41,37 +22,7 @@
|
||||
"SORT_UPDATED": "Last update",
|
||||
"SORT_CREATED": "Created at",
|
||||
"APPLY": "Apply filters",
|
||||
"CLEAR": "Clear filters",
|
||||
"HIDE": "Hide filters"
|
||||
},
|
||||
"KPI": {
|
||||
"TOTAL": "Total on page",
|
||||
"PENDING_PIX": "Awaiting PIX",
|
||||
"CHECKIN_TODAY": "Check-in today",
|
||||
"REVENUE_TODAY": "Revenue today"
|
||||
},
|
||||
"PILLS": {
|
||||
"ALL": "All",
|
||||
"DRAFT": "Draft",
|
||||
"PENDING_PAYMENT": "Awaiting PIX",
|
||||
"CONFIRMED": "Confirmed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"QUICK_DATE": {
|
||||
"TODAY": "Today",
|
||||
"TOMORROW": "Tomorrow",
|
||||
"WEEK": "7 days",
|
||||
"ALL": "All"
|
||||
},
|
||||
"CARD": {
|
||||
"CHECK_IN": "Check-in",
|
||||
"AMOUNT": "Amount",
|
||||
"TODAY": "Today",
|
||||
"TOMORROW": "Tomorrow",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PIX_EXPIRED": "Expired",
|
||||
"PIX_EXPIRES_IN_MIN": "Expires in {minutes}min",
|
||||
"PIX_EXPIRES_IN_HR": "Expires in {hours}h"
|
||||
"CLEAR": "Clear filters"
|
||||
},
|
||||
"TABLE": {
|
||||
"CUSTOMER": "Customer",
|
||||
@ -84,29 +35,14 @@
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"STATUS": {
|
||||
"SCHEDULED": "Scheduled",
|
||||
"DRAFT": "Draft",
|
||||
"PENDING_PAYMENT": "Awaiting payment",
|
||||
"ACTIVE": "Active",
|
||||
"CONFIRMED": "Confirmed",
|
||||
"COMPLETED": "Completed",
|
||||
"CANCELLED": "Cancelled",
|
||||
"DRAFT": "Draft"
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"COPY_PIX": "Copy Pix",
|
||||
"MORE": "More",
|
||||
"REGENERATE_PIX": "Resend PIX",
|
||||
"MARK_AS_PAID": "Mark as paid",
|
||||
"MARK_AS_PAID_CONFIRM": "Mark this reservation as manually paid?",
|
||||
"MARKED_AS_PAID": "Reservation marked as paid.",
|
||||
"MARK_AS_PAID_FAILED": "Failed to mark as paid.",
|
||||
"CANCEL": "Cancel reservation",
|
||||
"CANCEL_REASON_PROMPT": "Cancellation reason (optional):",
|
||||
"CANCELLED": "Reservation cancelled.",
|
||||
"CANCEL_FAILED": "Failed to cancel.",
|
||||
"PIX_REGENERATED": "New PIX generated and sent.",
|
||||
"PIX_REGENERATE_FAILED": "Failed to resend PIX."
|
||||
"COPY_PIX": "Copy Pix"
|
||||
},
|
||||
"KANBAN": {
|
||||
"EMPTY_COLUMN": "No reservations in this status."
|
||||
@ -143,99 +79,6 @@
|
||||
"PIX_COPY_FAILED": "Unable to copy Pix."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_ORCHESTRATOR_EDITOR": {
|
||||
"MISSING_DELIMITER_PREFIX": "This prompt was saved before the sections split. All content appears in the \"System Base Prompt\" field. Click",
|
||||
"MISSING_DELIMITER_BUTTON": "Restore Default",
|
||||
"MISSING_DELIMITER_SUFFIX": "to get automatic splitting, or manually move assistant content to the correct field."
|
||||
},
|
||||
"CAPTAIN_ROLETA": {
|
||||
"HEADER": "Roulette — Redeem",
|
||||
"TAB_REDEEM": "Redeem",
|
||||
"TAB_REPORT": "Report",
|
||||
"REDEEM": {
|
||||
"TITLE": "Deliver prize to guest",
|
||||
"DESC": "Type the code the guest showed on WhatsApp and confirm the redemption. Jasmine automatically sends a confirmation to the guest.",
|
||||
"CODE_LABEL": "Coupon code",
|
||||
"CODE_PLACEHOLDER": "Ex: ABC123",
|
||||
"NOTES_LABEL": "Note (optional)",
|
||||
"NOTES_PLACEHOLDER": "Any detail about the redemption",
|
||||
"SUBMIT": "Confirm redemption",
|
||||
"SUBMITTING": "Registering...",
|
||||
"SUCCESS_PREFIX": "{prize} delivered to ",
|
||||
"SUCCESS_FULL": "✅ {prize} delivered to {name}.",
|
||||
"ERROR_FULL": "⚠️ {message}",
|
||||
"FALLBACK_CLIENT": "guest",
|
||||
"ERROR_EMPTY_CODE": "Type the code printed on the guest's coupon.",
|
||||
"ERROR_NOT_FOUND": "Code not found. Check the spelling.",
|
||||
"ERROR_ALREADY_REDEEMED": "This coupon has already been redeemed.",
|
||||
"ERROR_NO_PRIZE": "This coupon landed on \"No luck\" — nothing to deliver.",
|
||||
"ERROR_NO_RECEPTIONIST": "Log in again and try once more.",
|
||||
"ERROR_RPC_FAILED": "Error calling the reservations server.",
|
||||
"ERROR_EXCEPTION": "Something broke. Tell the tech team.",
|
||||
"ERROR_DEFAULT": "Not registered."
|
||||
},
|
||||
"HISTORY": {
|
||||
"TITLE": "Active coupons (last 7 days)",
|
||||
"LOADING": "Loading...",
|
||||
"EMPTY": "No active coupons in recent days.",
|
||||
"LOAD_ERROR": "Error loading pending coupons",
|
||||
"COL_CODE": "Code",
|
||||
"COL_PRIZE": "Prize",
|
||||
"COL_CLIENT": "Guest",
|
||||
"COL_GENERATED": "Generated",
|
||||
"COL_STATUS": "Redemption",
|
||||
"STATUS_REDEEMED_PREFIX": "✅ ",
|
||||
"STATUS_PENDING": "⏳ Pending"
|
||||
},
|
||||
"REPORT": {
|
||||
"TITLE": "Redemptions by receptionist",
|
||||
"DESC": "Anti-fraud: flags those redeeming well above team average.",
|
||||
"PERIOD_7": "Last 7 days",
|
||||
"PERIOD_14": "Last 14 days",
|
||||
"PERIOD_30": "Last 30 days",
|
||||
"LOADING": "Loading report...",
|
||||
"EMPTY": "No redemptions in this period.",
|
||||
"LOAD_ERROR": "Error loading report",
|
||||
"KPI_TOTAL": "Total redemptions",
|
||||
"KPI_AVG": "Average per person",
|
||||
"KPI_COUNT": "Active receptionists",
|
||||
"KPI_THRESHOLD": "Alert threshold",
|
||||
"KPI_THRESHOLD_PREFIX": "≥ ",
|
||||
"COL_RECEPTIONIST": "Receptionist",
|
||||
"COL_TOTAL": "Total",
|
||||
"COL_BRINDES": "Gifts",
|
||||
"COL_DESCONTOS": "Discounts",
|
||||
"COL_SUM_DISCOUNT": "Σ % discount",
|
||||
"COL_STATUS": "Status",
|
||||
"STATUS_ANOMALY": "⚠️ Above average",
|
||||
"STATUS_NORMAL": "Normal",
|
||||
"FOOTER_HINT": "Alert triggers when a receptionist has ≥ {threshold} redemptions (minimum 5, or 2.5× team average). Verify guest conversations on WhatsApp to confirm."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_FUNNEL": {
|
||||
"HEADER": "Conversion Funnel",
|
||||
"DESC": "Track the customer journey from price inquiry to paid Pix. Identifies where customers drop off.",
|
||||
"PERIOD_7": "7 days",
|
||||
"PERIOD_30": "30 days",
|
||||
"PERIOD_60": "60 days",
|
||||
"PERIOD_90": "90 days",
|
||||
"LOADING": "Loading funnel...",
|
||||
"EMPTY": "No Captain conversations in this period. Try a different range.",
|
||||
"LOAD_ERROR": "Error loading funnel",
|
||||
"INSIGHT_LABEL": "Biggest drop-off point",
|
||||
"INSIGHT_FULL": "{lost} customers dropped between \"{from}\" and \"{to}\" — that's {pct} of those who reached the previous stage.",
|
||||
"FUNNEL_TITLE": "Overall funnel ({count} conversations analyzed)",
|
||||
"BY_SUITE_TITLE": "By suite category",
|
||||
"BY_SUITE_HEADER": "Suite",
|
||||
"BY_SUITE_FOOTER": "Category detected by mention in conversation content. Conversations without specific mention are not shown in this breakdown.",
|
||||
"STAGES": {
|
||||
"price_inquiry": "Asked price",
|
||||
"price_answered": "Received quote",
|
||||
"reservation_drafted": "Reservation started",
|
||||
"pix_generated": "Pix generated",
|
||||
"pix_paid": "Pix paid"
|
||||
}
|
||||
},
|
||||
"CAPTAIN_SETTINGS": {
|
||||
"TITLE": "Captain Settings",
|
||||
"UNITS": {
|
||||
@ -434,506 +277,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"BANNER": {
|
||||
"RESPONSES": "You have used more than 80% of your responses limit. To continue using Captain AI, please upgrade.",
|
||||
"DOCUMENTS": "Documents limit reached. Please upgrade to continue using Captain AI."
|
||||
},
|
||||
"FORM": {
|
||||
"CANCEL": "Cancel",
|
||||
"CREATE": "Create",
|
||||
"EDIT": "Update"
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"SEARCH_PLACEHOLDER": "Search FAQs...",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||
"ERROR_MESSAGE": "An error occurred while approving FAQs. Try again."
|
||||
},
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete FAQs?",
|
||||
"DESCRIPTION": "Are you sure you want to delete selected FAQs? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete all",
|
||||
"SUCCESS_MESSAGE": "FAQs deleted successfully",
|
||||
"ERROR_MESSAGE": "An error occurred while deleting FAQs, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete this FAQ?",
|
||||
"DESCRIPTION": "",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"SUCCESS_MESSAGE": "FAQ deleted successfully",
|
||||
"ERROR_MESSAGE": "An error occurred while deleting FAQ, please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_REPORTS": {
|
||||
"TITLE": "AI Reports",
|
||||
"DESC": "Weekly AI-generated insights based on each unit's conversations.",
|
||||
"LOADING": "Loading reports...",
|
||||
"ALL_UNITS": "All units",
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"UNITS_GROUP": "Pix Units",
|
||||
"INBOXES_GROUP": "Inboxes",
|
||||
"TABS": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"INSIGHTS": "AI Insights",
|
||||
"OPERATIONAL": "Operational",
|
||||
"EXECUTIVE": "Executive",
|
||||
"LANDING_PAGES": "Landing Pages",
|
||||
"RETENTION": "Retention"
|
||||
},
|
||||
"RETENTION": {
|
||||
"PERIOD_LABEL": "Period",
|
||||
"PERIOD_THIS_MONTH": "This month",
|
||||
"PERIOD_LAST_30": "Last 30 days",
|
||||
"PERIOD_LAST_90": "Last 90 days",
|
||||
"PERIOD_CUSTOM": "Custom",
|
||||
"APPLY": "Apply",
|
||||
"NO_DATA": "No data.",
|
||||
"KPI": {
|
||||
"ACTIVE": "Active customers",
|
||||
"ACTIVE_HINT": "last interaction within 30 days",
|
||||
"RECURRING": "Recurring",
|
||||
"RECURRING_HINT": "≥2 qualified interactions in 90 days",
|
||||
"RETURN_30D": "30-day return rate",
|
||||
"RETURN_30D_HINT": "returned to interact within 7 days",
|
||||
"PIX_CONVERSION": "Pix conversion",
|
||||
"PIX_CONVERSION_HINT": "{paid} paid out of {generated} generated"
|
||||
},
|
||||
"FLOW": {
|
||||
"TITLE": "Period flow",
|
||||
"NEW_IN_PERIOD": "new in period",
|
||||
"RETURNED_IN_PERIOD": "returned in period",
|
||||
"TOTAL_TOUCHES": "total interactions",
|
||||
"BASE_STATUS": "Current base status",
|
||||
"SLEEPING": "{count} sleeping",
|
||||
"SLEEPING_HINT": "30-90d without contact",
|
||||
"AT_RISK": "{count} at risk",
|
||||
"AT_RISK_HINT": "90-180d without contact",
|
||||
"CHURNED": "{count} inactive",
|
||||
"CHURNED_HINT": "180d+ without contact"
|
||||
},
|
||||
"COHORT": {
|
||||
"TITLE": "Cohort matrix",
|
||||
"SUBTITLE": "% of each cohort's customers who returned to interact in M+N months.",
|
||||
"EXPORT_CSV": "Export CSV",
|
||||
"EMPTY": "No cohort data yet.",
|
||||
"COL_COHORT": "Cohort",
|
||||
"COL_SIZE": "Size",
|
||||
"CELL_TITLE": "{count} active contacts ({rate}%)"
|
||||
},
|
||||
"BADGE": {
|
||||
"STATUS_FIRST": "First contact",
|
||||
"STATUS_INACTIVE": "Inactive",
|
||||
"STATUS_AT_RISK": "At risk",
|
||||
"STATUS_SLEEPING": "Sleeping",
|
||||
"STATUS_RECURRING": "Recurring",
|
||||
"STATUS_ACTIVE": "Active",
|
||||
"LAST_INTERACTION": "last {days}",
|
||||
"INTERACTIONS_LABEL": "interaction | interactions",
|
||||
"INTERACTIONS_TITLE": "Qualified interactions (≥2+2 messages)",
|
||||
"ONE_SHOT_LABEL": "one-shot",
|
||||
"ONE_SHOT_TITLE": "One-shot consultations (≥1+1)",
|
||||
"PIX_LABEL": "Pix paid",
|
||||
"PIX_TITLE": "Pix generated / reservations paid",
|
||||
"DAYS_TODAY": "today",
|
||||
"DAYS_YESTERDAY": "yesterday",
|
||||
"DAYS_RECENT": "{days} days ago",
|
||||
"DAYS_ONE_MONTH": "about 1 month ago",
|
||||
"DAYS_MONTHS": "{months} months ago",
|
||||
"DAYS_YEARS": "{years} years ago"
|
||||
},
|
||||
"ERRORS": {
|
||||
"SUMMARY": "Failed to load retention KPIs",
|
||||
"COHORT": "Failed to load cohort"
|
||||
}
|
||||
},
|
||||
"EXECUTIVE": {
|
||||
"LOADING": "Loading executive digest...",
|
||||
"NO_DATA": "No insights for the period. Run the weekly analysis to see data here.",
|
||||
"TITLE": "Executive Digest",
|
||||
"SUBTITLE": "Same report sent to Mattermost, with drill-down and filters.",
|
||||
"DELIVER_BUTTON": "Send to Mattermost now",
|
||||
"DELIVER_SUCCESS": "Digest queued. It will arrive in Mattermost shortly.",
|
||||
"DELIVER_ERROR": "Failed to dispatch the digest. Check Rails log.",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
"UNITS_ANALYZED": "Units analyzed",
|
||||
"INSIGHTS_COUNT": "Insights generated",
|
||||
"UNIT_TABLE": "Comparative by unit",
|
||||
"COL_UNIT": "Unit",
|
||||
"COL_CONVS": "Conversations",
|
||||
"COL_DELTA": "vs previous week",
|
||||
"COL_AI_RATE": "AI success rate",
|
||||
"COL_FAILURES": "Failures",
|
||||
"AI_FAILURES": "Where Angelina failed (click to see conversations)",
|
||||
"OPPORTUNITIES": "Opportunities — what customers asked for",
|
||||
"OPPORTUNITIES_HINT": "Click an opportunity to see real conversations where it was mentioned.",
|
||||
"COMPLAINTS": "Recurring complaints",
|
||||
"PRAISES": "Recurring praises",
|
||||
"RECOMMENDATIONS": "AI Recommendations",
|
||||
"DRILLDOWN_TITLE": "Related conversations",
|
||||
"NO_CONVERSATIONS_FOUND": "No conversations found with those keywords in the period.",
|
||||
"NO_CONVERSATIONS_HINT": "The insight description is an AI abstraction. If no keyword matches the literal conversation text, nothing is returned. Try clicking a more specific item.",
|
||||
"SEARCH_TOKENS": "Keywords searched",
|
||||
"OPEN_CONVERSATION": "Open in Chatwoot"
|
||||
},
|
||||
"LP": {
|
||||
"LOADING": "Loading data...",
|
||||
"NO_DATA": "No clicks recorded yet. Integrate the pixel on your landing page to see data here.",
|
||||
"TOTAL_CLICKS": "Total Clicks",
|
||||
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
|
||||
"CONVERSION_RATE": "Conversion Rate",
|
||||
"TOTAL_DROPOFF": "Drop-off (No conversation)",
|
||||
"DROPOFF_RATE": "Drop-off Rate",
|
||||
"UNIQUE_CONTACTS": "Unique contacts converted",
|
||||
"FUNNEL_TITLE": "Landing page funnel",
|
||||
"DAILY_TREND": "Daily click vs conversion trend",
|
||||
"BY_SOURCE": "Clicks by Source",
|
||||
"BY_CAMPAIGN": "Clicks by Campaign",
|
||||
"BY_HOSTNAME": "Clicks by Landing Page",
|
||||
"CLICKS": "clicks",
|
||||
"CONV": "conv",
|
||||
"REFRESH": "Refresh",
|
||||
"LEGEND_CLICKS": "Clicks",
|
||||
"LEGEND_CONVERSIONS": "Conversions"
|
||||
},
|
||||
"FILTER_DATE": {
|
||||
"LABEL": "Period:",
|
||||
"START": "Start Date",
|
||||
"END": "End Date",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"THIS_WEEK": "This week",
|
||||
"LAST_WEEK": "Last Week",
|
||||
"CURRENT_MONTH": "Current Month",
|
||||
"LAST_MONTH": "Last Month",
|
||||
"CUSTOM": "Custom",
|
||||
"SEPARATOR": "-"
|
||||
},
|
||||
"INSIGHT": {
|
||||
"CONVERSATIONS": "conversations",
|
||||
"MESSAGES": "messages",
|
||||
"TOP_TOPICS": "Top Topics",
|
||||
"AI_FAILURES": "AI Failures",
|
||||
"BULLET": "•",
|
||||
"COUNT_PREFIX": "(",
|
||||
"COUNT_SUFFIX": ")",
|
||||
"TIMES": "x",
|
||||
"SENTIMENT": "Sentiment",
|
||||
"SENTIMENT_POSITIVE": "Positive",
|
||||
"SENTIMENT_NEGATIVE": "Negative",
|
||||
"SENTIMENT_NEUTRAL": "Neutral",
|
||||
"PRAISES": "Customer Praises",
|
||||
"COMPLAINTS": "Complaints",
|
||||
"FAQ_GAPS": "FAQ Gaps",
|
||||
"FAQ_GAPS_HINT": "Questions customers ask that the agent doesn't cover",
|
||||
"MOST_REQUESTED_SUITES": "Most Requested Suites",
|
||||
"PRICE_REACTIONS": "Price Reactions",
|
||||
"PRICE_OBJECTIONS": "price objections",
|
||||
"RECOMMENDATIONS": "Recommendations",
|
||||
"SHOW_DETAILS": "View full analysis",
|
||||
"HIDE_DETAILS": "Hide details"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE": "No reports generated",
|
||||
"MESSAGE": "Generate a new report to analyze support performance."
|
||||
},
|
||||
"GENERATE": {
|
||||
"BUTTON": "Generate Analysis",
|
||||
"SUCCESS": "Report requested successfully! It may take a few minutes.",
|
||||
"ERROR": "Error requesting report generation.",
|
||||
"DATE_REQUIRED": "Please select start and end dates."
|
||||
},
|
||||
"STATUS": {
|
||||
"PENDING": "Pending",
|
||||
"PROCESSING": "Processing",
|
||||
"DONE": "Completed",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"OPERATIONAL": {
|
||||
"LOADING": "Loading operational data...",
|
||||
"NO_DATA": "No operational data for the selected period.",
|
||||
"CONVERSATIONS_SECTION": "Conversations",
|
||||
"RESERVATIONS_SECTION": "Reservations",
|
||||
"TOTAL": "Total",
|
||||
"RESOLVED": "Resolved",
|
||||
"OPEN": "Open",
|
||||
"AVG_RESOLUTION": "Avg resolution time",
|
||||
"RES_TOTAL": "Total reservations",
|
||||
"RES_PAID": "Paid",
|
||||
"RES_EXPIRED": "Expired",
|
||||
"RES_REVENUE": "Paid revenue",
|
||||
"BY_INBOX": "Volume by inbox",
|
||||
"RESOLUTION_RATE_TOOLTIP": "Resolution rate",
|
||||
"DAILY_DIST": "Daily distribution",
|
||||
"HOURLY_DIST": "Hourly distribution",
|
||||
"PEAK": "Peak"
|
||||
},
|
||||
"DASHBOARD": {
|
||||
"TOTAL_CONVERSATIONS": "Analyzed conversations",
|
||||
"AVG_SENTIMENT": "Avg. positive sentiment",
|
||||
"FAQ_GAPS_TOTAL": "FAQ gaps identified",
|
||||
"WEEKS_ANALYZED": "weeks analyzed",
|
||||
"NO_DATA": "Not enough data. Generate more AI reports to see the dashboard.",
|
||||
"SENTIMENT_TREND": "Sentiment trend by week",
|
||||
"FAILURES_RANKING": "Agent failure ranking",
|
||||
"FAILURES_RANKING_HINT": "Most frequent situations where the AI couldn't respond well",
|
||||
"FAQ_PRIORITY": "Priority FAQs to create",
|
||||
"FAQ_PRIORITY_HINT": "Questions customers ask most that aren't in the FAQ yet",
|
||||
"CUSTOMER_BEHAVIOR": "Customer behavior",
|
||||
"TOP_TOPICS_TITLE": "Most discussed topics",
|
||||
"SUITES_TITLE": "Most requested suites",
|
||||
"COMPLAINTS_TREND": "Complaints volume by week",
|
||||
"HANDOFFS_TITLE": "Estimated handoffs to human",
|
||||
"HANDOFFS_HINT": "Based on AI failure frequency. Direct handoff tracking coming soon.",
|
||||
"TREND_UP": "rising",
|
||||
"TREND_DOWN": "falling",
|
||||
"TREND_STABLE": "stable",
|
||||
"WEEKS": "weeks"
|
||||
},
|
||||
"FAQ_QUICK_ADD": {
|
||||
"BUTTON": "Create FAQ",
|
||||
"TITLE": "Create FAQ from AI suggestion",
|
||||
"QUESTION_LABEL": "Question (suggested by AI)",
|
||||
"ANSWER_LABEL": "Answer",
|
||||
"ANSWER_PLACEHOLDER": "Write the answer to this question...",
|
||||
"ASSISTANT_LABEL": "AI Agent",
|
||||
"ASSISTANT_PLACEHOLDER": "Select the agent",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVE": "Save FAQ",
|
||||
"SUCCESS": "FAQ created successfully!",
|
||||
"ERROR": "Error creating FAQ. Please try again."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_LIFECYCLE": {
|
||||
"HEADER": "Customer Journey",
|
||||
"SUBTITLE": "Automated WhatsApp messages along the reservation lifecycle",
|
||||
"TABS": {
|
||||
"RULES": "Rules",
|
||||
"SETTINGS": "Settings",
|
||||
"HISTORY": "History"
|
||||
},
|
||||
"RULES": {
|
||||
"EMPTY": "No rules configured yet.",
|
||||
"CREATE": "New rule",
|
||||
"TEMPLATES_TITLE": "Ready-made templates",
|
||||
"COLUMNS": {
|
||||
"NAME": "Name",
|
||||
"EVENT": "Event",
|
||||
"OFFSET": "Offset",
|
||||
"FILTER": "Filter",
|
||||
"STATUS": "Status",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"STATUS": {
|
||||
"ENABLED": "Active",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"EDIT": "Edit",
|
||||
"DUPLICATE": "Duplicate",
|
||||
"TOGGLE": "Enable/Disable",
|
||||
"DELETE": "Delete"
|
||||
},
|
||||
"DELETE_CONFIRM": "Are you sure you want to delete this rule?",
|
||||
"TOAST": {
|
||||
"CREATED": "Rule created successfully.",
|
||||
"UPDATED": "Rule updated.",
|
||||
"DELETED": "Rule deleted."
|
||||
},
|
||||
"WIZARD": {
|
||||
"TITLE_CREATE": "New rule",
|
||||
"TITLE_EDIT": "Edit rule",
|
||||
"STEP_WHEN": "When?",
|
||||
"STEP_WHO": "Who?",
|
||||
"STEP_WHAT": "What?",
|
||||
"STEP_REVIEW": "Review",
|
||||
"NEXT": "Next",
|
||||
"BACK": "Back",
|
||||
"SAVE": "Save",
|
||||
"CANCEL": "Cancel",
|
||||
"OFFSET_UNIT_LABEL": "min",
|
||||
"STEP_LABELS": {
|
||||
"WHEN": "1. When?",
|
||||
"WHO": "2. Who?",
|
||||
"WHAT": "3. What?",
|
||||
"REVIEW_TAB": "4. Review"
|
||||
},
|
||||
"REVIEW": {
|
||||
"NAME": "Name:",
|
||||
"EVENT": "Event:",
|
||||
"OFFSET": "Offset (min):",
|
||||
"UNITS": "Units:",
|
||||
"MESSAGE": "Message:"
|
||||
},
|
||||
"FIELDS": {
|
||||
"NAME": "Rule name",
|
||||
"DESCRIPTION": "Description",
|
||||
"EVENT": "Trigger event",
|
||||
"OFFSET_VALUE": "Value",
|
||||
"OFFSET_UNIT": "Unit",
|
||||
"OFFSET_DIRECTION": "Direction",
|
||||
"UNITS": "Units",
|
||||
"CATEGORIAS": "Suite categories",
|
||||
"PERMANENCIAS": "Stay types",
|
||||
"MESSAGE_TYPE": "Message type",
|
||||
"MESSAGE_BODY": "Message body",
|
||||
"PRIORITY": "Priority",
|
||||
"ENABLED": "Rule active"
|
||||
},
|
||||
"OFFSET_UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"OFFSET_DIRECTIONS": {
|
||||
"BEFORE": "Before",
|
||||
"AFTER": "After"
|
||||
},
|
||||
"EVENTS": {
|
||||
"RESERVATION_CONFIRMED": "Reservation confirmed (Pix paid)",
|
||||
"CHECKIN_SCHEDULED_AT": "Check-in time",
|
||||
"CHECKOUT_SCHEDULED_AT": "Check-out time",
|
||||
"RESERVATION_CANCELLED": "Reservation cancelled",
|
||||
"RESERVATION_NO_SHOW": "No-show"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"TEXT": "Plain text",
|
||||
"BUTTONS": "Text with buttons",
|
||||
"LIST": "List menu",
|
||||
"URL_BUTTON": "Link button"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": {
|
||||
"GUARDS_TITLE": "Anti-spam guards",
|
||||
"QUIET_HOURS_ENABLED": "Enable quiet hours",
|
||||
"QUIET_HOURS_FROM": "From",
|
||||
"QUIET_HOURS_TO": "To",
|
||||
"MIN_INTERVAL": "Minimum interval between messages (min)",
|
||||
"MIN_INTERVAL_HELP": "0 disables",
|
||||
"PAUSE_ON_REPLY": "Pause if customer replied",
|
||||
"PAUSE_ON_REPLY_WINDOW": "Window (min)",
|
||||
"OPT_OUT_LABEL": "Opt-out label",
|
||||
"MAX_PER_RESERVATION_INFO": "Maximum of 5 messages per reservation (not configurable)",
|
||||
"CONCIERGE_TITLE": "Concierge (Sofia) per Unit",
|
||||
"CONCIERGE_INBOX": "WhatsApp Inbox",
|
||||
"CONCIERGE_PERSONA": "Persona name",
|
||||
"CONCIERGE_KNOWLEDGE": "Knowledge base (markdown)",
|
||||
"CONCIERGE_VARIABLES": "Unit variables",
|
||||
"CONCIERGE_VARIABLE_KEY": "Key",
|
||||
"CONCIERGE_VARIABLE_VALUE": "Value",
|
||||
"CONCIERGE_ADD_VARIABLE": "Add variable",
|
||||
"CONCIERGE_CONFIGURED": "Configured",
|
||||
"CONCIERGE_NOT_CONFIGURED": "Not configured",
|
||||
"SAVE": "Save changes",
|
||||
"TOAST": {
|
||||
"SAVED": "Settings saved.",
|
||||
"CONCIERGE_SAVED": "Unit concierge updated."
|
||||
}
|
||||
},
|
||||
"HISTORY": {
|
||||
"EMPTY": "No deliveries recorded.",
|
||||
"COLUMNS": {
|
||||
"RULE": "Rule",
|
||||
"CUSTOMER": "Customer",
|
||||
"RESERVATION": "Reservation",
|
||||
"STATUS": "Status",
|
||||
"FIRE_AT": "Fired at",
|
||||
"REASON": "Reason",
|
||||
"ACTIONS": ""
|
||||
},
|
||||
"STATUS": {
|
||||
"SCHEDULED": "Scheduled",
|
||||
"SENT": "Sent",
|
||||
"SKIPPED": "Skipped",
|
||||
"FAILED": "Failed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"FILTERS": {
|
||||
"STATUS": "Status",
|
||||
"RULE": "Rule",
|
||||
"FROM": "From",
|
||||
"TO": "To",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PREVIEW": "Preview",
|
||||
"TOTAL": "total",
|
||||
"PAGINATION": {
|
||||
"PREV": "Prev",
|
||||
"NEXT": "Next"
|
||||
},
|
||||
"MODAL": {
|
||||
"TITLE": "Message preview",
|
||||
"CLOSE": "Close",
|
||||
"RULE": "Rule",
|
||||
"STATUS": "Status",
|
||||
"REASON": "Reason",
|
||||
"ERROR": "Error",
|
||||
"FIRE_AT": "Fire at",
|
||||
"SENT_AT": "Sent at",
|
||||
"RENDERED": "Rendered",
|
||||
"RESERVATION_ID": "Reservation #"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_HERMES_BUILDER": {
|
||||
"TITLE": "Agent Builder",
|
||||
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
|
||||
"HEADER_TITLE": "Agent Builder",
|
||||
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
|
||||
"RESET": "Clear conversation",
|
||||
"RESET_CONFIRM": "Clear current conversation with the Builder?",
|
||||
"EMPTY_STATE": "Ready to create a new Hermes agent? Click \"Start creation\" and the Builder will guide you.",
|
||||
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
|
||||
"SEND": "Send",
|
||||
"SESSION_LABEL": "Session:",
|
||||
"SEND_FAILED": "Send failed: {message}",
|
||||
"RESET_FAILED": "Failed to clear session.",
|
||||
"START": "Start creation",
|
||||
"TAB_CHAT": "Chat (Builder)",
|
||||
"TAB_VERIFY": "Verification",
|
||||
"VERIFY": {
|
||||
"TITLE": "Agent verification",
|
||||
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
|
||||
"NO_ASSISTANTS": "No Hermes agents registered",
|
||||
"RUN": "Run check",
|
||||
"RUNNING": "Checking...",
|
||||
"REPAIR": "Repair",
|
||||
"REPAIRING": "Repairing...",
|
||||
"OK_LABEL": "OK",
|
||||
"FAILS_LABEL": "failures",
|
||||
"WARN_LABEL": "warnings",
|
||||
"OF_TOTAL": "of {total} checks",
|
||||
"VERDICT_PASS": "Ready to ship",
|
||||
"VERDICT_FAIL": "Critical failures — fix first",
|
||||
"EMPTY": "Select an agent and click Run check to start verification.",
|
||||
"EMPTY_RESULTS": "No checks returned — agent removed?",
|
||||
"REPAIR_FAILED": "Failed: {message}",
|
||||
"REPAIR_OK": "Repaired: {message}",
|
||||
"FETCH_FAILED": "Error loading assistants: {message}",
|
||||
"VALIDATE_FAILED": "Validation failed: {message}",
|
||||
"CATEGORY_DB": "Database",
|
||||
"CATEGORY_PRICING": "Pricing",
|
||||
"CATEGORY_ROUTING": "Captain → Hermes routing",
|
||||
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
|
||||
"CATEGORY_MCP": "Registered MCP tools",
|
||||
"CATEGORY_OTHER": "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -381,7 +381,6 @@
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_MEMORIES": "Customer Memories",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"RESERVATION": "Reservation",
|
||||
@ -407,28 +406,6 @@
|
||||
"PARTIALLY_FULFILLED": "Partially Fulfilled",
|
||||
"UNFULFILLED": "Unfulfilled"
|
||||
}
|
||||
},
|
||||
"CONTACT_MEMORIES": {
|
||||
"LOADING": "Loading memories…",
|
||||
"EMPTY_TITLE": "No memories yet",
|
||||
"EMPTY_HINT": "Memories appear here as the customer chats over time.",
|
||||
"FORGET": "Forget",
|
||||
"FORGET_ALL": "Forget all memories",
|
||||
"CONFIRM_DELETE": "Forget this memory?",
|
||||
"CONFIRM_FORGET_ALL": "Forget ALL memories for this customer? This cannot be undone after 30 days.",
|
||||
"ERROR_LOADING": "Failed to load memories",
|
||||
"CONFIDENCE": "Confidence",
|
||||
"TYPE_LABELS": {
|
||||
"preferencia": "Preference",
|
||||
"data_comemorativa": "Date",
|
||||
"vinculo_social": "Social",
|
||||
"padrao_comportamental": "Pattern",
|
||||
"reclamacao": "Complaint",
|
||||
"feedback_positivo": "Praise",
|
||||
"restricao": "Restriction",
|
||||
"vinculo_comercial": "Commercial",
|
||||
"contexto_pessoal": "Personal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SCHEDULED_MESSAGES": {
|
||||
|
||||
@ -104,14 +104,6 @@
|
||||
"ERROR": "Failed to update audio transcription setting"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Aggressive conversation alert (master switch)",
|
||||
"NOTE": "When on, agents receive a banner + sound + OS notification when a conversation is reopened and at 5/15/28 min without reply. Each agent can still turn it off in their profile — this is the account-wide master. Off here = nobody receives.",
|
||||
"API": {
|
||||
"SUCCESS": "Aggressive alert setting updated",
|
||||
"ERROR": "Failed to update aggressive alert setting"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Inactivity duration for resolution",
|
||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import aggressiveBanner from './aggressiveBanner.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -45,7 +44,6 @@ import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...aggressiveBanner,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -385,11 +385,7 @@
|
||||
"ASSISTANTS": "Assistants",
|
||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||
"NEW_ASSISTANT": "Create Assistant",
|
||||
"EMPTY_LIST": "No assistants found, please create one to get started",
|
||||
"ENGINE_HERMES": "Hermes",
|
||||
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
|
||||
"ENGINE_INTERNO": "Internal",
|
||||
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
|
||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TITLE": "Copilot",
|
||||
@ -693,11 +689,6 @@
|
||||
"PLACEHOLDER": "Describe how and where this scenario will be used",
|
||||
"ERROR": "Scenario description is required"
|
||||
},
|
||||
"TRIGGER_KEYWORDS": {
|
||||
"LABEL": "When to use this scenario (triggers)",
|
||||
"PLACEHOLDER": "USE WHEN:\n- Customer wants to book\n- Customer asks about price\n\nEXAMPLE TRIGGERS:\n- \"I want to book saturday\"\n- \"how much is it?\"\n\nDO NOT USE WHEN:\n- Customer just wants to see photos",
|
||||
"HELP": "Optional but recommended. Use this field to teach the AI WHEN to route to this scenario. Clearer examples mean better routing accuracy."
|
||||
},
|
||||
"INSTRUCTION": {
|
||||
"LABEL": "How to handle",
|
||||
"PLACEHOLDER": "Describe how and where this scenario will be handled",
|
||||
|
||||
@ -36,11 +36,11 @@
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolved by bot",
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Transferred to human",
|
||||
"NAME": "Handoff Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
@ -281,35 +281,6 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"TABS": {
|
||||
"OVERVIEW": "Overview",
|
||||
"LEADS": "New × Returning"
|
||||
},
|
||||
"LEADS": {
|
||||
"TITLE": "New × Returning",
|
||||
"INBOX_LABEL": "Inbox:",
|
||||
"EMPTY": "No conversations in this period.",
|
||||
"TOTAL": "Total conversations in the period: {count}",
|
||||
"METRICS": {
|
||||
"NEW_LEADS": {
|
||||
"LABEL": "New leads",
|
||||
"INFO": "Conversations from contacts who never spoke to any inbox of the network before."
|
||||
},
|
||||
"RETURNING": {
|
||||
"LABEL": "Returning",
|
||||
"INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago."
|
||||
},
|
||||
"OTHERS": {
|
||||
"LABEL": "Others",
|
||||
"INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago."
|
||||
}
|
||||
},
|
||||
"CHART": {
|
||||
"NEW_LEADS": "New",
|
||||
"RETURNING": "Returning",
|
||||
"OTHERS": "Others"
|
||||
}
|
||||
},
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
@ -534,32 +505,20 @@
|
||||
"HEADER": "Bot Reports",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot in the period"
|
||||
"LABEL": "No. of Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Outgoing messages",
|
||||
"TOOLTIP": "Total number of outgoing messages — includes the bot AND humans (Chatwoot UI or WhatsApp echo)"
|
||||
"LABEL": "Total Responses",
|
||||
"TOOLTIP": "Total number of responses sent by the bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolved by bot %",
|
||||
"TOOLTIP": "Conversations the bot resolved alone (no human reply, via UI or WhatsApp) ÷ total conversations × 100"
|
||||
"LABEL": "Resolution Rate",
|
||||
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Transferred to human %",
|
||||
"TOOLTIP": "Conversations transferred to human (auto by Jasmine + manual takeover) ÷ total conversations × 100. Together with the resolution rate, the gear closes the math (the rest are still open, snoozed, or abandoned)."
|
||||
},
|
||||
"BOT_RESOLUTIONS": {
|
||||
"LABEL": "Resolved by bot",
|
||||
"TOOLTIP": "Absolute count: conversations the bot closed alone, with no human reply (UI or WhatsApp)"
|
||||
},
|
||||
"AUTO_HANDOFFS": {
|
||||
"LABEL": "Auto handoff (Jasmine)",
|
||||
"TOOLTIP": "Conversations where Jasmine explicitly called bot_handoff! — typically tool loop, timeout, max turns reached or LLM intent classified as handoff"
|
||||
},
|
||||
"MANUAL_TAKEOVERS": {
|
||||
"LABEL": "Manual takeover (agent)",
|
||||
"TOOLTIP": "Conversations where a human replied (Chatwoot UI or WhatsApp echo) without Jasmine triggering bot_handoff! first — the agent took over silently"
|
||||
"LABEL": "Handoff Rate",
|
||||
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -687,47 +646,5 @@
|
||||
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
||||
"RESOLUTION_COUNT": "Resolution Count",
|
||||
"CONVERSATIONS": "No. of conversations"
|
||||
},
|
||||
"DIRECTORY_DASHBOARD": {
|
||||
"HEADER": "Directory Dashboard",
|
||||
"BANNER": {
|
||||
"TITLE": "Channel adoption — not the full picture.",
|
||||
"BODY": "These numbers measure the digital channel only (Jasmine + reservations created via app). Conversations attended manually that closed at the reception are not yet captured (manual marking is in progress)."
|
||||
},
|
||||
"HEADLINE_NUMBERS": "Headline numbers",
|
||||
"METRICS": {
|
||||
"LEADS_TOTAL": {
|
||||
"LABEL": "Leads (total)",
|
||||
"TOOLTIP": "All conversations created in the period (new + returning)"
|
||||
},
|
||||
"LEADS_NEW": {
|
||||
"LABEL": "New leads",
|
||||
"TOOLTIP": "First-ever conversation of the contact in any inbox of the network"
|
||||
},
|
||||
"LEADS_RETURNING": {
|
||||
"LABEL": "Returning leads",
|
||||
"TOOLTIP": "Contact had at least one prior conversation"
|
||||
},
|
||||
"CONVERSION_RATE": {
|
||||
"LABEL": "Lead → Paid reservation",
|
||||
"TOOLTIP": "Paid reservations ÷ total leads × 100. Adoption proxy, not full operation."
|
||||
}
|
||||
},
|
||||
"FUNNEL": {
|
||||
"TITLE": "Funnel",
|
||||
"STAGE_LEADS": "Leads",
|
||||
"STAGE_RESERVATIONS": "Reservations created",
|
||||
"STAGE_PAID": "Paid"
|
||||
},
|
||||
"BENCHMARK": {
|
||||
"TITLE": "Inbox benchmarking by brand",
|
||||
"BRAND_AVG": "brand avg.",
|
||||
"COL_INBOX": "Inbox",
|
||||
"COL_LEADS": "Leads",
|
||||
"COL_CREATED": "Created",
|
||||
"COL_PAID": "Paid",
|
||||
"COL_RATE": "Conv. rate",
|
||||
"COL_VS_BRAND": "vs brand"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Aggressive conversation alert",
|
||||
"SECTION_NOTE": "Triggers a banner, sound and OS notification when a conversation is reopened and every 5/15/28 minutes without reply. Only clears when YOU reply. Turn off for a silent shift — but own the risk.",
|
||||
"TITLE": "Receive aggressive alerts",
|
||||
"NOTE": "When on, you get a banner + sound + notification when a conversation reopens or goes X minutes without reply. Turn off at your own risk."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||
@ -121,15 +115,6 @@
|
||||
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||
},
|
||||
"AGGRESSIVE_ALERT_SECTION": {
|
||||
"TITLE": "Stalled conversation alert",
|
||||
"NOTE": "Red banner that appears at the top of the panel when a conversation has been waiting for a reply for 5+ minutes.",
|
||||
"DESCRIPTION": "Red banner shown when a conversation has no reply for 5+ minutes. Useful to avoid losing customers, but can be intrusive if you don't handle every inbox.",
|
||||
"ENABLED": "Enable stalled conversation alert",
|
||||
"APPLY_TO_ALL": "Apply to all inboxes",
|
||||
"INBOX_HINT": "Pick the inboxes where you want to receive the alert:",
|
||||
"NO_INBOXES": "No inboxes registered."
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Audio Alerts",
|
||||
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
|
||||
@ -358,11 +343,6 @@
|
||||
"CAPTAIN_PIX_UNITS": "Pix Units",
|
||||
"CAPTAIN_GALLERY": "Gallery",
|
||||
"CAPTAIN_RESERVATIONS": "Reservations",
|
||||
"CAPTAIN_ROLETA": "Roulette — Redeem",
|
||||
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
|
||||
"CAPTAIN_FUNNEL": "Conversion Funnel",
|
||||
"CAPTAIN_LIFECYCLE": "Customer Journey",
|
||||
"CAPTAIN_REPORTS": "AI Reports",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
@ -398,7 +378,6 @@
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_DIRECTORY_DASHBOARD": "Directory Dashboard",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
@ -1042,5 +1021,44 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Delete",
|
||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"CAPTAIN_REPORTS": {
|
||||
"TITLE": "AI Reports",
|
||||
"DESC": "Weekly AI-generated insights based on conversations from each inbox.",
|
||||
"LOADING": "Loading reports...",
|
||||
"ALL_UNITS": "All units",
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"TABS": {
|
||||
"INSIGHTS": "AI Insights",
|
||||
"OPERATIONAL": "Operational"
|
||||
},
|
||||
"STATUS": {
|
||||
"PENDING": "Pending",
|
||||
"PROCESSING": "Processing",
|
||||
"DONE": "Completed",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"GENERATE": {
|
||||
"BUTTON": "Generate Analysis",
|
||||
"SUCCESS": "Analysis queued successfully! It will be available shortly.",
|
||||
"ERROR": "Error requesting analysis. Please try again."
|
||||
},
|
||||
"INSIGHT": {
|
||||
"CONVERSATIONS": "conversations",
|
||||
"MESSAGES": "messages",
|
||||
"TOP_TOPICS": "Top Topics",
|
||||
"AI_FAILURES": "AI Improvement Points",
|
||||
"COUNT_PREFIX": "(",
|
||||
"COUNT_SUFFIX": ")",
|
||||
"BULLET": "•"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE": "No analysis available",
|
||||
"MESSAGE": "Click Generate Analysis to create the first weekly report with conversation insights."
|
||||
},
|
||||
"OPERATIONAL": {
|
||||
"COMING_SOON": "Coming Soon",
|
||||
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,13 +94,6 @@
|
||||
"ADMIN_SUCCESS_MESSAGE": "Um e-mail com instruções de redefinição de senha foi enviado para o agente",
|
||||
"SUCCESS_MESSAGE": "Senha do agente redefinida com sucesso",
|
||||
"ERROR_MESSAGE": "Não foi possível conectar ao servidor Woot, por favor tente novamente mais tarde"
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"LABEL": "Alerta agressivo — caixas de entrada",
|
||||
"DESCRIPTION": "Escolha em quais caixas de entrada este agente verá o banner de conversa reaberta e de inatividade.",
|
||||
"ALL_INBOXES": "Em todas as caixas de entrada",
|
||||
"PICK_INBOXES": "Selecione as caixas de entrada",
|
||||
"NONE_WARNING": "Nenhuma caixa selecionada — este agente não verá o alerta agressivo."
|
||||
}
|
||||
},
|
||||
"SEARCH": {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"HEADLINE_REOPENED": "🚨 Conversa reaberta — responda agora",
|
||||
"HEADLINE_5": "⏰ {minutes} min sem resposta",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN SEM RESPOSTA — responda!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN SEM RESPOSTA — conversa vai fechar!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversas aguardando resposta",
|
||||
"EXPLANATION": "Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente — volta se não responder.",
|
||||
"KIND_REOPENED": "reabriu agora",
|
||||
"KIND_WAITING": "{minutes} min sem resposta",
|
||||
"HIDE_ONE": "Esconder",
|
||||
"HIDE_ONE_TITLE": "Esconde temporariamente — volta se não responder",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
@ -2,25 +2,6 @@
|
||||
"CAPTAIN_RESERVATIONS": {
|
||||
"HEADER": "Reservas",
|
||||
"EMPTY": "Nenhuma reserva encontrada.",
|
||||
"CREATE_SUCCESS": "Reserva criada com sucesso.",
|
||||
"CREATE_ERROR": "Erro ao criar reserva.",
|
||||
"NEW_RESERVATION_MODAL": {
|
||||
"TITLE": "Nova Reserva",
|
||||
"CONFIRM": "Criar Reserva",
|
||||
"CANCEL": "Cancelar",
|
||||
"FIELDS": {
|
||||
"CONTACT_ID": "ID do Contato",
|
||||
"CONTACT_ID_PLACEHOLDER": "Digite o ID do contato",
|
||||
"INBOX": "Canal (Caixa de Entrada)",
|
||||
"INBOX_PLACEHOLDER": "Selecione a caixa de entrada",
|
||||
"STATUS": "Status da Reserva",
|
||||
"STATUS_PLACEHOLDER": "Selecione o status",
|
||||
"SUITE_IDENTIFIER": "Identificador da Suíte (Ex: 101, Suíte Master)",
|
||||
"CHECK_IN": "Check-in",
|
||||
"CHECK_OUT": "Check-out",
|
||||
"TOTAL_AMOUNT": "Valor Total"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"LIST": "Lista",
|
||||
"KANBAN": "Kanban",
|
||||
@ -41,37 +22,7 @@
|
||||
"SORT_UPDATED": "Última atualização",
|
||||
"SORT_CREATED": "Data de criação",
|
||||
"APPLY": "Aplicar filtros",
|
||||
"CLEAR": "Limpar filtros",
|
||||
"HIDE": "Ocultar filtros"
|
||||
},
|
||||
"KPI": {
|
||||
"TOTAL": "Total filtrado",
|
||||
"PENDING_PIX": "Aguardando PIX",
|
||||
"CHECKIN_TODAY": "Check-in hoje",
|
||||
"REVENUE_TODAY": "Receita hoje"
|
||||
},
|
||||
"PILLS": {
|
||||
"ALL": "Todas",
|
||||
"DRAFT": "Rascunho",
|
||||
"PENDING_PAYMENT": "Aguardando PIX",
|
||||
"CONFIRMED": "Confirmadas",
|
||||
"CANCELLED": "Canceladas"
|
||||
},
|
||||
"QUICK_DATE": {
|
||||
"TODAY": "Hoje",
|
||||
"TOMORROW": "Amanhã",
|
||||
"WEEK": "7 dias",
|
||||
"ALL": "Tudo"
|
||||
},
|
||||
"CARD": {
|
||||
"CHECK_IN": "Check-in",
|
||||
"AMOUNT": "Valor",
|
||||
"TODAY": "Hoje",
|
||||
"TOMORROW": "Amanhã",
|
||||
"YESTERDAY": "Ontem",
|
||||
"PIX_EXPIRED": "Expirado",
|
||||
"PIX_EXPIRES_IN_MIN": "Expira em {minutes}min",
|
||||
"PIX_EXPIRES_IN_HR": "Expira em {hours}h"
|
||||
"CLEAR": "Limpar filtros"
|
||||
},
|
||||
"TABLE": {
|
||||
"CUSTOMER": "Cliente",
|
||||
@ -84,29 +35,14 @@
|
||||
"ACTIONS": "Ações"
|
||||
},
|
||||
"STATUS": {
|
||||
"SCHEDULED": "Agendado",
|
||||
"DRAFT": "Rascunho",
|
||||
"PENDING_PAYMENT": "Aguardando pagamento",
|
||||
"ACTIVE": "Ativa",
|
||||
"CONFIRMED": "Confirmada",
|
||||
"COMPLETED": "Concluída",
|
||||
"CANCELLED": "Cancelada",
|
||||
"DRAFT": "Rascunho"
|
||||
"CANCELLED": "Cancelada"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"OPEN_CONVERSATION": "Abrir conversa",
|
||||
"COPY_PIX": "Copiar Pix",
|
||||
"MORE": "Mais",
|
||||
"REGENERATE_PIX": "Reenviar PIX",
|
||||
"MARK_AS_PAID": "Marcar como paga",
|
||||
"MARK_AS_PAID_CONFIRM": "Marcar esta reserva como paga manualmente?",
|
||||
"MARKED_AS_PAID": "Reserva marcada como paga.",
|
||||
"MARK_AS_PAID_FAILED": "Falha ao marcar como paga.",
|
||||
"CANCEL": "Cancelar reserva",
|
||||
"CANCEL_REASON_PROMPT": "Motivo do cancelamento (opcional):",
|
||||
"CANCELLED": "Reserva cancelada.",
|
||||
"CANCEL_FAILED": "Falha ao cancelar.",
|
||||
"PIX_REGENERATED": "Novo PIX gerado e enviado.",
|
||||
"PIX_REGENERATE_FAILED": "Falha ao reenviar PIX."
|
||||
"COPY_PIX": "Copiar Pix"
|
||||
},
|
||||
"KANBAN": {
|
||||
"EMPTY_COLUMN": "Nenhuma reserva neste status."
|
||||
@ -143,99 +79,6 @@
|
||||
"PIX_COPY_FAILED": "Não foi possível copiar o Pix."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_ORCHESTRATOR_EDITOR": {
|
||||
"MISSING_DELIMITER_PREFIX": "Este prompt foi salvo antes da separação em seções. Todo o conteúdo aparece no campo \"Prompt Base do Sistema\". Clique em",
|
||||
"MISSING_DELIMITER_BUTTON": "Restaurar Padrão",
|
||||
"MISSING_DELIMITER_SUFFIX": "para obter a separação automática, ou reorganize manualmente movendo o conteúdo do assistente para o campo correto."
|
||||
},
|
||||
"CAPTAIN_ROLETA": {
|
||||
"HEADER": "Roleta da Sorte — Resgate",
|
||||
"TAB_REDEEM": "Resgate",
|
||||
"TAB_REPORT": "Relatório",
|
||||
"REDEEM": {
|
||||
"TITLE": "Entregar prêmio ao cliente",
|
||||
"DESC": "Digite o código que o cliente mostrou no WhatsApp e confirme o resgate. A Jasmine manda automaticamente uma confirmação pro cliente.",
|
||||
"CODE_LABEL": "Código do cupom",
|
||||
"CODE_PLACEHOLDER": "Ex: ABC123",
|
||||
"NOTES_LABEL": "Observação (opcional)",
|
||||
"NOTES_PLACEHOLDER": "Qualquer detalhe sobre o resgate",
|
||||
"SUBMIT": "Confirmar resgate",
|
||||
"SUBMITTING": "Registrando...",
|
||||
"SUCCESS_PREFIX": "{prize} entregue para ",
|
||||
"SUCCESS_FULL": "✅ {prize} entregue para {name}.",
|
||||
"ERROR_FULL": "⚠️ {message}",
|
||||
"FALLBACK_CLIENT": "cliente",
|
||||
"ERROR_EMPTY_CODE": "Digite o código impresso no cupom do cliente.",
|
||||
"ERROR_NOT_FOUND": "Código não encontrado. Confere se digitou direito.",
|
||||
"ERROR_ALREADY_REDEEMED": "Esse cupom já foi resgatado antes.",
|
||||
"ERROR_NO_PRIZE": "Esse cupom caiu em \"Sem sorte\" — não tem nada pra entregar.",
|
||||
"ERROR_NO_RECEPTIONIST": "Faz login de novo e tenta outra vez.",
|
||||
"ERROR_RPC_FAILED": "Erro ao chamar o servidor de reservas.",
|
||||
"ERROR_EXCEPTION": "Algo quebrou. Avisa o time técnico.",
|
||||
"ERROR_DEFAULT": "Não foi registrado."
|
||||
},
|
||||
"HISTORY": {
|
||||
"TITLE": "Cupons ativos (últimos 7 dias)",
|
||||
"LOADING": "Carregando...",
|
||||
"EMPTY": "Nenhum cupom ativo nos últimos dias.",
|
||||
"LOAD_ERROR": "Erro ao carregar cupons pendentes",
|
||||
"COL_CODE": "Código",
|
||||
"COL_PRIZE": "Prêmio",
|
||||
"COL_CLIENT": "Cliente",
|
||||
"COL_GENERATED": "Gerado",
|
||||
"COL_STATUS": "Resgate",
|
||||
"STATUS_REDEEMED_PREFIX": "✅ ",
|
||||
"STATUS_PENDING": "⏳ Aguardando"
|
||||
},
|
||||
"REPORT": {
|
||||
"TITLE": "Relatório de resgates por recepcionista",
|
||||
"DESC": "Anti-fraude: flaga quem resgatou muito acima da média da equipe.",
|
||||
"PERIOD_7": "Últimos 7 dias",
|
||||
"PERIOD_14": "Últimos 14 dias",
|
||||
"PERIOD_30": "Últimos 30 dias",
|
||||
"LOADING": "Carregando relatório...",
|
||||
"EMPTY": "Nenhum resgate registrado nesse período.",
|
||||
"LOAD_ERROR": "Erro ao carregar relatório",
|
||||
"KPI_TOTAL": "Total resgates",
|
||||
"KPI_AVG": "Média por pessoa",
|
||||
"KPI_COUNT": "Recepcionistas ativas",
|
||||
"KPI_THRESHOLD": "Limite de alerta",
|
||||
"KPI_THRESHOLD_PREFIX": "≥ ",
|
||||
"COL_RECEPTIONIST": "Recepcionista",
|
||||
"COL_TOTAL": "Total",
|
||||
"COL_BRINDES": "Brindes",
|
||||
"COL_DESCONTOS": "Descontos",
|
||||
"COL_SUM_DISCOUNT": "Σ % desconto",
|
||||
"COL_STATUS": "Status",
|
||||
"STATUS_ANOMALY": "⚠️ Acima da média",
|
||||
"STATUS_NORMAL": "Normal",
|
||||
"FOOTER_HINT": "Alerta dispara quando a recepcionista tem ≥ {threshold} resgates (mínimo 5, ou 2,5× a média da equipe). Investigue conversas do cliente no WhatsApp pra confirmar."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_FUNNEL": {
|
||||
"HEADER": "Funil de Conversão",
|
||||
"DESC": "Acompanhe a jornada do cliente da pergunta de preço até o Pix pago. Identifica onde os clientes mais desistem.",
|
||||
"PERIOD_7": "7 dias",
|
||||
"PERIOD_30": "30 dias",
|
||||
"PERIOD_60": "60 dias",
|
||||
"PERIOD_90": "90 dias",
|
||||
"LOADING": "Carregando funil...",
|
||||
"EMPTY": "Nenhuma conversa do Captain nesse período. Ajuste o intervalo.",
|
||||
"LOAD_ERROR": "Erro ao carregar funil",
|
||||
"INSIGHT_LABEL": "Maior ponto de abandono",
|
||||
"INSIGHT_FULL": "{lost} clientes caíram entre \"{from}\" e \"{to}\" — isso é {pct} dos que chegaram na etapa anterior.",
|
||||
"FUNNEL_TITLE": "Funil geral ({count} conversas analisadas)",
|
||||
"BY_SUITE_TITLE": "Por categoria de suíte",
|
||||
"BY_SUITE_HEADER": "Suíte",
|
||||
"BY_SUITE_FOOTER": "Categoria detectada por menção no conteúdo da conversa. Conversas sem menção específica não aparecem nesse breakdown.",
|
||||
"STAGES": {
|
||||
"price_inquiry": "Perguntou preço",
|
||||
"price_answered": "Recebeu cotação",
|
||||
"reservation_drafted": "Reserva iniciada",
|
||||
"pix_generated": "Pix gerado",
|
||||
"pix_paid": "Pix pago"
|
||||
}
|
||||
},
|
||||
"CAPTAIN_SETTINGS": {
|
||||
"TITLE": "Configurações do Captain",
|
||||
"UNITS": {
|
||||
@ -369,8 +212,7 @@
|
||||
"Ações"
|
||||
],
|
||||
"ADD_NEW_ITEM": "Adicione fotos na galeria",
|
||||
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes.",
|
||||
"VIEW_URL": "URL da Imagem"
|
||||
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes."
|
||||
},
|
||||
"DELETE": {
|
||||
"CONFIRM": {
|
||||
@ -410,14 +252,14 @@
|
||||
"SPECIFIC_HELP": "Essas fotos serão usadas somente na caixa de entrada {inbox}."
|
||||
},
|
||||
"SUITE_CATEGORY": {
|
||||
"LABEL": "Categoria",
|
||||
"LABEL": "Categoria da suíte",
|
||||
"PLACEHOLDER": "Ex: Hidromassagem",
|
||||
"ERROR": "A categoria é obrigatória"
|
||||
},
|
||||
"SUITE_NUMBER": {
|
||||
"LABEL": "Nome/identificador",
|
||||
"LABEL": "Número/identificador da suíte",
|
||||
"PLACEHOLDER": "Ex: 101",
|
||||
"ERROR": "O identificador é obrigatório"
|
||||
"ERROR": "O identificador da suíte é obrigatório"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Descrição da foto",
|
||||
@ -435,506 +277,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"BANNER": {
|
||||
"RESPONSES": "Você usou mais de 80% do seu limite de respostas. Para continuar usando o Capitão IA, faça um upgrade.",
|
||||
"DOCUMENTS": "Limite de documentos atingido. Faça um upgrade para continuar usando o Capitão IA."
|
||||
},
|
||||
"FORM": {
|
||||
"CANCEL": "Cancelar",
|
||||
"CREATE": "Criar",
|
||||
"EDIT": "Atualizar"
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "FAQs Pendentes",
|
||||
"ADD_NEW": "Criar nova FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversação #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selecionado",
|
||||
"SELECT_ALL": "Selecionar todos ({count})",
|
||||
"UNSELECT_ALL": "Desmarcar todos ({count})",
|
||||
"SEARCH_PLACEHOLDER": "Pesquisar FAQs...",
|
||||
"BULK_APPROVE_BUTTON": "Aprovar",
|
||||
"BULK_DELETE_BUTTON": "Excluir",
|
||||
"BULK_APPROVE": {
|
||||
"SUCCESS_MESSAGE": "Perguntas Frequentes aprovadas com sucesso",
|
||||
"ERROR_MESSAGE": "Ocorreu um erro ao aproveitar as Perguntas Frequentes. Tente novamente."
|
||||
},
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Excluir as Perguntas Frequentes?",
|
||||
"DESCRIPTION": "Tem certeza que deseja excluir as Perguntas Frequentes selecionadas? Esta ação não pode ser desfeita.",
|
||||
"CONFIRM": "Sim, excluir todas",
|
||||
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso",
|
||||
"ERROR_MESSAGE": "Ocorreu um erro ao excluir as Perguntas Frequentes, por favor tente novamente."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Tem certeza que deseja excluir o FAQ?",
|
||||
"DESCRIPTION": "",
|
||||
"CONFIRM": "Sim, excluir",
|
||||
"SUCCESS_MESSAGE": "FAQ excluída com sucesso",
|
||||
"ERROR_MESSAGE": "Ocorreu um erro ao excluir a FAQ, por favor tente novamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_REPORTS": {
|
||||
"TITLE": "Relatórios IA",
|
||||
"DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.",
|
||||
"LOADING": "Carregando relatórios...",
|
||||
"ALL_UNITS": "Todas as unidades",
|
||||
"ALL_INBOXES": "Todas as caixas de entrada",
|
||||
"UNITS_GROUP": "Unidades Pix",
|
||||
"INBOXES_GROUP": "Caixas de Entrada",
|
||||
"TABS": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"INSIGHTS": "Insights IA",
|
||||
"OPERATIONAL": "Operacional",
|
||||
"EXECUTIVE": "Executivo",
|
||||
"LANDING_PAGES": "Landing Pages",
|
||||
"RETENTION": "Retenção"
|
||||
},
|
||||
"RETENTION": {
|
||||
"PERIOD_LABEL": "Período",
|
||||
"PERIOD_THIS_MONTH": "Este mês",
|
||||
"PERIOD_LAST_30": "Últimos 30 dias",
|
||||
"PERIOD_LAST_90": "Últimos 90 dias",
|
||||
"PERIOD_CUSTOM": "Personalizado",
|
||||
"APPLY": "Aplicar",
|
||||
"NO_DATA": "Sem dados.",
|
||||
"KPI": {
|
||||
"ACTIVE": "Clientes ativos",
|
||||
"ACTIVE_HINT": "última interação nos últimos 30 dias",
|
||||
"RECURRING": "Recorrentes",
|
||||
"RECURRING_HINT": "≥2 interações qualificadas em 90 dias",
|
||||
"RETURN_30D": "Taxa de retorno 30d",
|
||||
"RETURN_30D_HINT": "voltaram a interagir em 7 dias",
|
||||
"PIX_CONVERSION": "Conversão Pix",
|
||||
"PIX_CONVERSION_HINT": "{paid} pagos de {generated} gerados"
|
||||
},
|
||||
"FLOW": {
|
||||
"TITLE": "Fluxo do período",
|
||||
"NEW_IN_PERIOD": "novos no período",
|
||||
"RETURNED_IN_PERIOD": "retornaram no período",
|
||||
"TOTAL_TOUCHES": "interações totais",
|
||||
"BASE_STATUS": "Situação atual da base",
|
||||
"SLEEPING": "{count} adormecidos",
|
||||
"SLEEPING_HINT": "30-90d sem contato",
|
||||
"AT_RISK": "{count} em risco",
|
||||
"AT_RISK_HINT": "90-180d sem contato",
|
||||
"CHURNED": "{count} inativos",
|
||||
"CHURNED_HINT": "180d+ sem contato"
|
||||
},
|
||||
"COHORT": {
|
||||
"TITLE": "Matriz de cohort",
|
||||
"SUBTITLE": "% de clientes de cada cohort que voltaram a interagir em M+N meses.",
|
||||
"EXPORT_CSV": "Exportar CSV",
|
||||
"EMPTY": "Ainda não há cohorts com dados.",
|
||||
"COL_COHORT": "Cohort",
|
||||
"COL_SIZE": "Tamanho",
|
||||
"CELL_TITLE": "{count} contatos ativos ({rate}%)"
|
||||
},
|
||||
"BADGE": {
|
||||
"STATUS_FIRST": "Primeiro contato",
|
||||
"STATUS_INACTIVE": "Inativo",
|
||||
"STATUS_AT_RISK": "Em risco",
|
||||
"STATUS_SLEEPING": "Adormecido",
|
||||
"STATUS_RECURRING": "Recorrente",
|
||||
"STATUS_ACTIVE": "Ativo",
|
||||
"LAST_INTERACTION": "última {days}",
|
||||
"INTERACTIONS_LABEL": "interação | interações",
|
||||
"INTERACTIONS_TITLE": "Interações qualificadas (≥2+2 mensagens)",
|
||||
"ONE_SHOT_LABEL": "one-shot",
|
||||
"ONE_SHOT_TITLE": "Consultas one-shot (≥1+1)",
|
||||
"PIX_LABEL": "Pix pagos",
|
||||
"PIX_TITLE": "Pix gerados / reservas pagas",
|
||||
"DAYS_TODAY": "hoje",
|
||||
"DAYS_YESTERDAY": "ontem",
|
||||
"DAYS_RECENT": "há {days} dias",
|
||||
"DAYS_ONE_MONTH": "há cerca de 1 mês",
|
||||
"DAYS_MONTHS": "há {months} meses",
|
||||
"DAYS_YEARS": "há {years} anos"
|
||||
},
|
||||
"ERRORS": {
|
||||
"SUMMARY": "Falha ao carregar KPIs de retenção",
|
||||
"COHORT": "Falha ao carregar cohort"
|
||||
}
|
||||
},
|
||||
"EXECUTIVE": {
|
||||
"LOADING": "Carregando digest executivo...",
|
||||
"NO_DATA": "Sem insights gerados para o período. Rode a análise semanal pra ver os dados aqui.",
|
||||
"TITLE": "Digest Executivo",
|
||||
"SUBTITLE": "Mesmo relatório enviado ao Mattermost, com drill-down e filtros.",
|
||||
"DELIVER_BUTTON": "Enviar ao Mattermost agora",
|
||||
"DELIVER_SUCCESS": "Digest enfileirado. Vai chegar no Mattermost em instantes.",
|
||||
"DELIVER_ERROR": "Falha ao disparar o digest. Veja o log do Rails.",
|
||||
"CONVERSATIONS": "Conversas",
|
||||
"MESSAGES": "Mensagens",
|
||||
"UNITS_ANALYZED": "Unidades analisadas",
|
||||
"INSIGHTS_COUNT": "Insights gerados",
|
||||
"UNIT_TABLE": "Comparativo por unidade",
|
||||
"COL_UNIT": "Unidade",
|
||||
"COL_CONVS": "Conversas",
|
||||
"COL_DELTA": "vs semana anterior",
|
||||
"COL_AI_RATE": "Taxa de acerto IA",
|
||||
"COL_FAILURES": "Falhas",
|
||||
"AI_FAILURES": "Onde a Angelina errou (clique pra ver conversas)",
|
||||
"OPPORTUNITIES": "Oportunidades — o que clientes pediram",
|
||||
"OPPORTUNITIES_HINT": "Clique em uma oportunidade pra ver as conversas reais onde foi mencionada.",
|
||||
"COMPLAINTS": "Reclamações recorrentes",
|
||||
"PRAISES": "Elogios recorrentes",
|
||||
"RECOMMENDATIONS": "Recomendações da IA",
|
||||
"DRILLDOWN_TITLE": "Conversas relacionadas",
|
||||
"NO_CONVERSATIONS_FOUND": "Nenhuma conversa encontrada com essas palavras no período.",
|
||||
"NO_CONVERSATIONS_HINT": "A descrição do insight é uma abstração da IA. Se nenhuma palavra-chave bateu com o texto literal das conversas, nada é retornado. Tente clicar em outro item mais específico.",
|
||||
"SEARCH_TOKENS": "Palavras buscadas",
|
||||
"OPEN_CONVERSATION": "Abrir no Chatwoot"
|
||||
},
|
||||
"LP": {
|
||||
"LOADING": "Carregando dados...",
|
||||
"NO_DATA": "Nenhum clique registrado ainda. Integre o pixel na landing page para ver os dados aqui.",
|
||||
"TOTAL_CLICKS": "Total de Cliques",
|
||||
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
|
||||
"CONVERSION_RATE": "Taxa de Conversão",
|
||||
"TOTAL_DROPOFF": "Perdas (sem conversa)",
|
||||
"DROPOFF_RATE": "Taxa de Perda",
|
||||
"UNIQUE_CONTACTS": "Contatos únicos convertidos",
|
||||
"FUNNEL_TITLE": "Funil da landing page",
|
||||
"DAILY_TREND": "Tendência diária de cliques vs conversões",
|
||||
"BY_SOURCE": "Cliques por Origem",
|
||||
"BY_CAMPAIGN": "Cliques por Campanha",
|
||||
"BY_HOSTNAME": "Cliques por Landing Page",
|
||||
"CLICKS": "cliques",
|
||||
"CONV": "conv",
|
||||
"REFRESH": "Atualizar",
|
||||
"LEGEND_CLICKS": "Cliques",
|
||||
"LEGEND_CONVERSIONS": "Conversões"
|
||||
},
|
||||
"FILTER_DATE": {
|
||||
"LABEL": "Período:",
|
||||
"START": "Data Início",
|
||||
"END": "Data Fim",
|
||||
"TODAY": "Hoje",
|
||||
"YESTERDAY": "Ontem",
|
||||
"LAST_7_DAYS": "Últimos 7 dias",
|
||||
"LAST_30_DAYS": "Últimos 30 dias",
|
||||
"THIS_WEEK": "Esta semana",
|
||||
"LAST_WEEK": "Semana Passada",
|
||||
"CURRENT_MONTH": "Mês Atual",
|
||||
"LAST_MONTH": "Mês Passado",
|
||||
"CUSTOM": "Personalizado",
|
||||
"SEPARATOR": "-"
|
||||
},
|
||||
"INSIGHT": {
|
||||
"CONVERSATIONS": "conversas",
|
||||
"MESSAGES": "mensagens",
|
||||
"TOP_TOPICS": "Principais tópicos",
|
||||
"AI_FAILURES": "Falhas da IA",
|
||||
"BULLET": "•",
|
||||
"COUNT_PREFIX": "(",
|
||||
"COUNT_SUFFIX": ")",
|
||||
"TIMES": "x",
|
||||
"SENTIMENT": "Sentimento",
|
||||
"SENTIMENT_POSITIVE": "Positivo",
|
||||
"SENTIMENT_NEGATIVE": "Negativo",
|
||||
"SENTIMENT_NEUTRAL": "Neutro",
|
||||
"PRAISES": "Elogios dos clientes",
|
||||
"COMPLAINTS": "Reclamações",
|
||||
"FAQ_GAPS": "Lacunas no FAQ",
|
||||
"FAQ_GAPS_HINT": "Perguntas que os clientes fazem mas o agente não cobre",
|
||||
"MOST_REQUESTED_SUITES": "Suítes mais pedidas",
|
||||
"PRICE_REACTIONS": "Reação a preços",
|
||||
"PRICE_OBJECTIONS": "objeções de preço",
|
||||
"RECOMMENDATIONS": "Recomendações",
|
||||
"SHOW_DETAILS": "Ver análise completa",
|
||||
"HIDE_DETAILS": "Ocultar detalhes"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE": "Nenhum relatório gerado",
|
||||
"MESSAGE": "Gere um novo relatório para analisar o desempenho do atendimento."
|
||||
},
|
||||
"GENERATE": {
|
||||
"BUTTON": "Gerar Análise",
|
||||
"SUCCESS": "Relatório solicitado com sucesso! Pode levar alguns minutos.",
|
||||
"ERROR": "Erro ao solicitar geração do relatório.",
|
||||
"DATE_REQUIRED": "Por favor, selecione as datas de início e fim."
|
||||
},
|
||||
"STATUS": {
|
||||
"PENDING": "Pendente",
|
||||
"PROCESSING": "Processando",
|
||||
"DONE": "Concluído",
|
||||
"FAILED": "Falhou"
|
||||
},
|
||||
"OPERATIONAL": {
|
||||
"LOADING": "Carregando dados operacionais...",
|
||||
"NO_DATA": "Sem dados operacionais para o período selecionado.",
|
||||
"CONVERSATIONS_SECTION": "Conversas",
|
||||
"RESERVATIONS_SECTION": "Reservas",
|
||||
"TOTAL": "Total",
|
||||
"RESOLVED": "Resolvidas",
|
||||
"OPEN": "Em aberto",
|
||||
"AVG_RESOLUTION": "Tempo médio de resolução",
|
||||
"RES_TOTAL": "Total de reservas",
|
||||
"RES_PAID": "Pagas",
|
||||
"RES_EXPIRED": "Expiradas",
|
||||
"RES_REVENUE": "Receita paga",
|
||||
"BY_INBOX": "Volume por canal",
|
||||
"RESOLUTION_RATE_TOOLTIP": "Taxa de resolução",
|
||||
"DAILY_DIST": "Distribuição por dia",
|
||||
"HOURLY_DIST": "Distribuição por hora",
|
||||
"PEAK": "Pico"
|
||||
},
|
||||
"DASHBOARD": {
|
||||
"TOTAL_CONVERSATIONS": "Conversas analisadas",
|
||||
"AVG_SENTIMENT": "Sentimento positivo médio",
|
||||
"FAQ_GAPS_TOTAL": "Lacunas de FAQ identificadas",
|
||||
"WEEKS_ANALYZED": "semanas analisadas",
|
||||
"NO_DATA": "Dados insuficientes. Gere mais relatórios de IA para ver o dashboard.",
|
||||
"SENTIMENT_TREND": "Tendência de sentimento por semana",
|
||||
"FAILURES_RANKING": "Ranking de falhas do agente",
|
||||
"FAILURES_RANKING_HINT": "Situações mais frequentes em que a IA não conseguiu responder bem",
|
||||
"FAQ_PRIORITY": "FAQ prioritário para criar",
|
||||
"FAQ_PRIORITY_HINT": "Perguntas que os clientes mais fazem e ainda não estão no FAQ",
|
||||
"CUSTOMER_BEHAVIOR": "Comportamento dos clientes",
|
||||
"TOP_TOPICS_TITLE": "Assuntos mais discutidos",
|
||||
"SUITES_TITLE": "Suítes mais solicitadas",
|
||||
"COMPLAINTS_TREND": "Volume de reclamações por semana",
|
||||
"HANDOFFS_TITLE": "Estimativa de transferências para humano",
|
||||
"HANDOFFS_HINT": "Baseado na frequência de falhas do agente. Rastreamento direto de transferências em breve.",
|
||||
"TREND_UP": "em alta",
|
||||
"TREND_DOWN": "em queda",
|
||||
"TREND_STABLE": "estável",
|
||||
"WEEKS": "semanas"
|
||||
},
|
||||
"FAQ_QUICK_ADD": {
|
||||
"BUTTON": "Criar FAQ",
|
||||
"TITLE": "Criar FAQ a partir da sugestão da IA",
|
||||
"QUESTION_LABEL": "Pergunta (sugerida pela IA)",
|
||||
"ANSWER_LABEL": "Resposta",
|
||||
"ANSWER_PLACEHOLDER": "Escreva a resposta para esta pergunta...",
|
||||
"ASSISTANT_LABEL": "Agente de IA",
|
||||
"ASSISTANT_PLACEHOLDER": "Selecione o agente",
|
||||
"CANCEL": "Cancelar",
|
||||
"SAVE": "Salvar FAQ",
|
||||
"SUCCESS": "FAQ criado com sucesso!",
|
||||
"ERROR": "Erro ao criar FAQ. Tente novamente."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_LIFECYCLE": {
|
||||
"HEADER": "Jornada do Cliente",
|
||||
"SUBTITLE": "Automação de mensagens WhatsApp no ciclo de vida da reserva",
|
||||
"TABS": {
|
||||
"RULES": "Regras",
|
||||
"SETTINGS": "Configurações",
|
||||
"HISTORY": "Histórico"
|
||||
},
|
||||
"RULES": {
|
||||
"EMPTY": "Nenhuma regra configurada ainda.",
|
||||
"CREATE": "Nova regra",
|
||||
"TEMPLATES_TITLE": "Templates prontos",
|
||||
"COLUMNS": {
|
||||
"NAME": "Nome",
|
||||
"EVENT": "Evento",
|
||||
"OFFSET": "Offset",
|
||||
"FILTER": "Filtro",
|
||||
"STATUS": "Status",
|
||||
"ACTIONS": "Ações"
|
||||
},
|
||||
"STATUS": {
|
||||
"ENABLED": "Ativo",
|
||||
"DISABLED": "Desativado"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"EDIT": "Editar",
|
||||
"DUPLICATE": "Duplicar",
|
||||
"TOGGLE": "Ativar/Desativar",
|
||||
"DELETE": "Excluir"
|
||||
},
|
||||
"DELETE_CONFIRM": "Tem certeza que deseja excluir esta regra?",
|
||||
"TOAST": {
|
||||
"CREATED": "Regra criada com sucesso.",
|
||||
"UPDATED": "Regra atualizada.",
|
||||
"DELETED": "Regra excluída."
|
||||
},
|
||||
"WIZARD": {
|
||||
"TITLE_CREATE": "Nova regra",
|
||||
"TITLE_EDIT": "Editar regra",
|
||||
"STEP_WHEN": "Quando?",
|
||||
"STEP_WHO": "Pra quem?",
|
||||
"STEP_WHAT": "O quê?",
|
||||
"STEP_REVIEW": "Revisão",
|
||||
"NEXT": "Próximo",
|
||||
"BACK": "Voltar",
|
||||
"SAVE": "Salvar",
|
||||
"CANCEL": "Cancelar",
|
||||
"OFFSET_UNIT_LABEL": "min",
|
||||
"STEP_LABELS": {
|
||||
"WHEN": "1. Quando?",
|
||||
"WHO": "2. Pra quem?",
|
||||
"WHAT": "3. O quê?",
|
||||
"REVIEW_TAB": "4. Revisão"
|
||||
},
|
||||
"REVIEW": {
|
||||
"NAME": "Nome:",
|
||||
"EVENT": "Evento:",
|
||||
"OFFSET": "Offset (min):",
|
||||
"UNITS": "Unidades:",
|
||||
"MESSAGE": "Mensagem:"
|
||||
},
|
||||
"FIELDS": {
|
||||
"NAME": "Nome da regra",
|
||||
"DESCRIPTION": "Descrição",
|
||||
"EVENT": "Evento gatilho",
|
||||
"OFFSET_VALUE": "Valor",
|
||||
"OFFSET_UNIT": "Unidade",
|
||||
"OFFSET_DIRECTION": "Direção",
|
||||
"UNITS": "Unidades",
|
||||
"CATEGORIAS": "Categorias de suíte",
|
||||
"PERMANENCIAS": "Tipos de permanência",
|
||||
"MESSAGE_TYPE": "Tipo de mensagem",
|
||||
"MESSAGE_BODY": "Texto da mensagem",
|
||||
"PRIORITY": "Prioridade",
|
||||
"ENABLED": "Regra ativa"
|
||||
},
|
||||
"OFFSET_UNITS": {
|
||||
"MINUTES": "Minutos",
|
||||
"HOURS": "Horas",
|
||||
"DAYS": "Dias"
|
||||
},
|
||||
"OFFSET_DIRECTIONS": {
|
||||
"BEFORE": "Antes",
|
||||
"AFTER": "Depois"
|
||||
},
|
||||
"EVENTS": {
|
||||
"RESERVATION_CONFIRMED": "Reserva confirmada (Pix pago)",
|
||||
"CHECKIN_SCHEDULED_AT": "Horário de check-in",
|
||||
"CHECKOUT_SCHEDULED_AT": "Horário de check-out",
|
||||
"RESERVATION_CANCELLED": "Reserva cancelada",
|
||||
"RESERVATION_NO_SHOW": "No-show"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"TEXT": "Texto simples",
|
||||
"BUTTONS": "Texto com botões",
|
||||
"LIST": "Menu de lista",
|
||||
"URL_BUTTON": "Botão de link"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS": {
|
||||
"GUARDS_TITLE": "Guards anti-spam",
|
||||
"QUIET_HOURS_ENABLED": "Ativar quiet hours",
|
||||
"QUIET_HOURS_FROM": "De",
|
||||
"QUIET_HOURS_TO": "Até",
|
||||
"MIN_INTERVAL": "Intervalo mínimo entre mensagens (min)",
|
||||
"MIN_INTERVAL_HELP": "0 desativa",
|
||||
"PAUSE_ON_REPLY": "Pausar se o cliente respondeu",
|
||||
"PAUSE_ON_REPLY_WINDOW": "Janela (min)",
|
||||
"OPT_OUT_LABEL": "Label de opt-out",
|
||||
"MAX_PER_RESERVATION_INFO": "Máximo de 5 mensagens por reserva (não configurável)",
|
||||
"CONCIERGE_TITLE": "Concierge (Sofia) por Unidade",
|
||||
"CONCIERGE_INBOX": "Inbox WhatsApp",
|
||||
"CONCIERGE_PERSONA": "Nome da persona",
|
||||
"CONCIERGE_KNOWLEDGE": "Base de conhecimento (markdown)",
|
||||
"CONCIERGE_VARIABLES": "Variáveis da unidade",
|
||||
"CONCIERGE_VARIABLE_KEY": "Chave",
|
||||
"CONCIERGE_VARIABLE_VALUE": "Valor",
|
||||
"CONCIERGE_ADD_VARIABLE": "Adicionar variável",
|
||||
"CONCIERGE_CONFIGURED": "Configurado",
|
||||
"CONCIERGE_NOT_CONFIGURED": "Não configurado",
|
||||
"SAVE": "Salvar alterações",
|
||||
"TOAST": {
|
||||
"SAVED": "Configurações salvas.",
|
||||
"CONCIERGE_SAVED": "Concierge da unidade atualizado."
|
||||
}
|
||||
},
|
||||
"HISTORY": {
|
||||
"EMPTY": "Nenhuma entrega registrada.",
|
||||
"COLUMNS": {
|
||||
"RULE": "Regra",
|
||||
"CUSTOMER": "Cliente",
|
||||
"RESERVATION": "Reserva",
|
||||
"STATUS": "Status",
|
||||
"FIRE_AT": "Disparado em",
|
||||
"REASON": "Motivo",
|
||||
"ACTIONS": ""
|
||||
},
|
||||
"STATUS": {
|
||||
"SCHEDULED": "Agendada",
|
||||
"SENT": "Enviada",
|
||||
"SKIPPED": "Pulada",
|
||||
"FAILED": "Falhou",
|
||||
"CANCELLED": "Cancelada"
|
||||
},
|
||||
"FILTERS": {
|
||||
"STATUS": "Status",
|
||||
"RULE": "Regra",
|
||||
"FROM": "De",
|
||||
"TO": "Até",
|
||||
"ALL": "Todas"
|
||||
},
|
||||
"PREVIEW": "Preview",
|
||||
"TOTAL": "total",
|
||||
"PAGINATION": {
|
||||
"PREV": "Anterior",
|
||||
"NEXT": "Próxima"
|
||||
},
|
||||
"MODAL": {
|
||||
"TITLE": "Preview da mensagem",
|
||||
"CLOSE": "Fechar",
|
||||
"RULE": "Regra",
|
||||
"STATUS": "Status",
|
||||
"REASON": "Motivo",
|
||||
"ERROR": "Erro",
|
||||
"FIRE_AT": "Fire at",
|
||||
"SENT_AT": "Sent at",
|
||||
"RENDERED": "Rendered",
|
||||
"RESERVATION_ID": "Reserva #"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_HERMES_BUILDER": {
|
||||
"TITLE": "Construtor de Agentes",
|
||||
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
|
||||
"HEADER_TITLE": "Construtor de Agentes",
|
||||
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
|
||||
"RESET": "Limpar conversa",
|
||||
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
|
||||
"EMPTY_STATE": "Pronto pra criar um novo agente Hermes? Clica em \"Iniciar criação\" e o Construtor te guia.",
|
||||
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
|
||||
"SEND": "Enviar",
|
||||
"SESSION_LABEL": "Sessão:",
|
||||
"SEND_FAILED": "Erro ao enviar: {message}",
|
||||
"RESET_FAILED": "Falha ao limpar sessão.",
|
||||
"START": "Iniciar criação",
|
||||
"TAB_CHAT": "Chat (Construtor)",
|
||||
"TAB_VERIFY": "Verificação",
|
||||
"VERIFY": {
|
||||
"TITLE": "Verificação de agente",
|
||||
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
|
||||
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
|
||||
"RUN": "Conferir agora",
|
||||
"RUNNING": "Conferindo...",
|
||||
"REPAIR": "Refazer",
|
||||
"REPAIRING": "Reparando...",
|
||||
"OK_LABEL": "OK",
|
||||
"FAILS_LABEL": "falhas",
|
||||
"WARN_LABEL": "atenção",
|
||||
"OF_TOTAL": "de {total} checks",
|
||||
"VERDICT_PASS": "Pode soltar",
|
||||
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
|
||||
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
|
||||
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
|
||||
"REPAIR_FAILED": "Falha: {message}",
|
||||
"REPAIR_OK": "Reparado: {message}",
|
||||
"FETCH_FAILED": "Erro carregando assistentes: {message}",
|
||||
"VALIDATE_FAILED": "Falha ao validar: {message}",
|
||||
"CATEGORY_DB": "Banco de dados",
|
||||
"CATEGORY_PRICING": "Preços",
|
||||
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
|
||||
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
|
||||
"CATEGORY_MCP": "Tools MCP registradas",
|
||||
"CATEGORY_OTHER": "Outros"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -370,7 +370,6 @@
|
||||
"CONVERSATION_LABELS": "Etiquetas da conversa",
|
||||
"CONVERSATION_INFO": "Informação da conversa",
|
||||
"CONTACT_NOTES": "Notas do contato",
|
||||
"CONTACT_MEMORIES": "Memórias do Cliente",
|
||||
"CONTACT_ATTRIBUTES": "Atributos do contato",
|
||||
"PREVIOUS_CONVERSATION": "Conversas anteriores",
|
||||
"RESERVATION": "Reserva",
|
||||
@ -396,28 +395,6 @@
|
||||
"PARTIALLY_FULFILLED": "Partially Fulfilled",
|
||||
"UNFULFILLED": "Unfulfilled"
|
||||
}
|
||||
},
|
||||
"CONTACT_MEMORIES": {
|
||||
"LOADING": "Carregando memórias…",
|
||||
"EMPTY_TITLE": "Nenhuma memória ainda",
|
||||
"EMPTY_HINT": "Memórias aparecem aqui conforme o cliente conversa.",
|
||||
"FORGET": "Esquecer",
|
||||
"FORGET_ALL": "Esquecer todas",
|
||||
"CONFIRM_DELETE": "Esquecer esta memória?",
|
||||
"CONFIRM_FORGET_ALL": "Esquecer TODAS as memórias deste cliente? Não pode ser desfeito após 30 dias.",
|
||||
"ERROR_LOADING": "Falha ao carregar memórias",
|
||||
"CONFIDENCE": "Confiança",
|
||||
"TYPE_LABELS": {
|
||||
"preferencia": "Preferência",
|
||||
"data_comemorativa": "Data especial",
|
||||
"vinculo_social": "Vínculo social",
|
||||
"padrao_comportamental": "Padrão",
|
||||
"reclamacao": "Reclamação",
|
||||
"feedback_positivo": "Elogio",
|
||||
"restricao": "Restrição",
|
||||
"vinculo_comercial": "Vínculo comercial",
|
||||
"contexto_pessoal": "Pessoal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SCHEDULED_MESSAGES": {
|
||||
|
||||
@ -104,14 +104,6 @@
|
||||
"ERROR": "Falha ao atualizar configuração de transcrição de áudio"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Alerta agressivo de conversa (master switch)",
|
||||
"NOTE": "Quando ligado, atendentes recebem banner + som + notificação do SO quando uma conversa é reaberta e a cada 5/15/28 min sem resposta. Cada agente ainda pode desligar pra si no próprio perfil — este toggle é o mestre da conta. Desligar aqui = ninguém recebe.",
|
||||
"API": {
|
||||
"SUCCESS": "Alerta agressivo atualizado",
|
||||
"ERROR": "Falha ao atualizar o alerta agressivo"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Tempo de inatividade para resolução",
|
||||
"HELP": "Tempo de inatividade após o qual a conversa deve ser encerrada automaticamente",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import aggressiveBanner from './aggressiveBanner.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -41,7 +40,6 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...aggressiveBanner,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -361,16 +361,12 @@
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"NAME": "Capitão",
|
||||
"HEADER_KNOW_MORE": "Saiba mais",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"ASSISTANT_SWITCHER": {
|
||||
"ASSISTANTS": "Assistentes",
|
||||
"SWITCH_ASSISTANT": "Alternar entre assistentes",
|
||||
"NEW_ASSISTANT": "Criar Assistente",
|
||||
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
|
||||
"ENGINE_HERMES": "Hermes",
|
||||
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
|
||||
"ENGINE_INTERNO": "Interno",
|
||||
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
|
||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||
"NEW_ASSISTANT": "Create Assistant",
|
||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TITLE": "Copiloto",
|
||||
@ -545,20 +541,13 @@
|
||||
"SECTION_DESCRIPTION": "Edite o prompt base que define o comportamento central da IA. Deixe vazio para usar o prompt padrão do sistema.",
|
||||
"LABEL": "Prompt do Orquestrador",
|
||||
"DESCRIPTION": "Este prompt controla como a IA toma decisões, classifica pedidos e faz handoff para agentes especializados.",
|
||||
"PLACEHOLDER": "Digite o prompt customizado aqui. Formatação Liquid é suportada.",
|
||||
"PLACEHOLDER": "Digite o prompt customizado aqui. Formatação Liquid ({{ variavel }}) é suportada.",
|
||||
"WARNING": "⚠️ Atenção: Alterar o prompt pode impactar todo o comportamento da IA. Sempre valide as mudanças antes de salvar. Use \"Restaurar Padrão\" para voltar ao comportamento original.",
|
||||
"SAVE_BUTTON": "Salvar Prompt",
|
||||
"RESET_BUTTON": "Restaurar Padrão",
|
||||
"USING_DEFAULT": "Usando prompt padrão do sistema",
|
||||
"USING_CUSTOM": "Usando prompt customizado",
|
||||
"VALIDATION_ERROR": "O prompt não pode ficar em branco. Use 'Restaurar Padrão' para voltar ao padrão do sistema.",
|
||||
"SYSTEM_LABEL": "Prompt Base do Sistema",
|
||||
"SYSTEM_DESCRIPTION": "Estrutura técnica do Captain — contexto, identidade e handoff. Altere só se souber o que está fazendo.",
|
||||
"SYSTEM_PLACEHOLDER": "Prompt base do sistema (contexto, identidade, handoff)...",
|
||||
"ASSISTANT_LABEL": "Instruções do Assistente",
|
||||
"ASSISTANT_DESCRIPTION": "Configure aqui o comportamento, regras, tom e personalidade específicos deste assistente.",
|
||||
"ASSISTANT_PLACEHOLDER": "Defina as instruções específicas do assistente: tom de voz, regras de negócio, restrições, fluxos de atendimento...",
|
||||
"DIVIDER_LABEL": "✏️ início das configurações do assistente"
|
||||
"VALIDATION_ERROR": "O prompt não pode ficar em branco. Use 'Restaurar Padrão' para voltar ao padrão do sistema."
|
||||
},
|
||||
"OPTIONS": {
|
||||
"EDIT_ASSISTANT": "Editar Assistente",
|
||||
@ -569,8 +558,8 @@
|
||||
"TITLE": "Não há assistentes disponíveis",
|
||||
"SUBTITLE": "Crie um assistente para fornecer respostas rápidas e precisas aos seus usuários. Ele pode aprender com seus artigos de ajuda e conversas passadas.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Assistente Capitão",
|
||||
"NOTE": "O Assistente Capitão interage diretamente com os clientes, aprende com seus documentos de ajuda e conversas passadas, e entrega respostas instantâneas e precisas. Ele lida com as perguntas iniciais, fornecendo resoluções rápidas antes de transferir para um agente quando necessário."
|
||||
"TITLE": "Captain Assistant",
|
||||
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
}
|
||||
},
|
||||
"GUARDRAILS": {
|
||||
@ -693,11 +682,6 @@
|
||||
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
|
||||
"ERROR": "Descrição do cenário é obrigatória"
|
||||
},
|
||||
"TRIGGER_KEYWORDS": {
|
||||
"LABEL": "Quando usar este cenário (gatilhos)",
|
||||
"PLACEHOLDER": "USE QUANDO:\n- Cliente quer reservar\n- Cliente pergunta preço\n\nEXEMPLOS DE GATILHOS:\n- \"quero reservar sábado\"\n- \"quanto custa?\"\n\nNÃO USE QUANDO:\n- Cliente só quer ver fotos",
|
||||
"HELP": "Opcional, mas recomendado. Use este campo para ensinar a IA QUANDO rotear para este cenário. Quanto mais claro e com exemplos, melhor a precisão do roteamento."
|
||||
},
|
||||
"INSTRUCTION": {
|
||||
"LABEL": "Como lidar",
|
||||
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
|
||||
@ -785,8 +769,8 @@
|
||||
"TITLE": "Nenhum documento disponível",
|
||||
"SUBTITLE": "Os documentos são usados pelo seu assistente para gerar perguntas frequentes. Pode importar documentos para fornecer um contexto para seu assistente.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Documento do Capitão",
|
||||
"NOTE": "Um documento no Capitão serve como um recurso de conhecimento para o assistente. Ao conectar sua central de ajuda ou guias, o Capitão pode analisar o conteúdo e fornecer respostas precisas para as consultas dos clientes."
|
||||
"TITLE": "Captain Document",
|
||||
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -798,118 +782,110 @@
|
||||
"SUBTITLE": "Crie ferramentas personalizadas para conectar com APIs e serviços externos, permitindo obter dados e agir por você.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Ferramentas Personalizadas",
|
||||
"NOTE": "As ferramentas personalizadas permitem que seu assistente interaja com APIs e serviços externos. Crie ferramentas para buscar dados, realizar ações ou integrar-se aos seus sistemas existentes para aprimorar as capacidades do seu assistente."
|
||||
"NOTE": "Ferramentas personalizadas permitem seu assistente interagir com APIs e serviços externos. Crie ferramentas para obter dados, realizar ações ou integre com seus sistemas existentes para melhorar as capacidades do seu assistente."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure sua ferramenta personalizada para conectar-se a APIs externas",
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Editar ferramenta",
|
||||
"DELETE_TOOL": "Excluir ferramenta"
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Criar Ferramenta Personalizada",
|
||||
"SUCCESS_MESSAGE": "Ferramenta personalizada criada com sucesso",
|
||||
"ERROR_MESSAGE": "Falha ao criar ferramenta personalizada"
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Editar Ferramenta Personalizada",
|
||||
"SUCCESS_MESSAGE": "Ferramenta personalizada atualizada com sucesso",
|
||||
"ERROR_MESSAGE": "Falha ao atualizar ferramenta personalizada"
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Excluir Ferramenta Personalizada",
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Tem certeza que deseja excluir está ferramenta customizável? Está ação não pode ser desfeita.",
|
||||
"CONFIRM": "Sim, excluir",
|
||||
"SUCCESS_MESSAGE": "Ferramenta personalizada excluída com sucesso",
|
||||
"ERROR_MESSAGE": "Falha ao excluir ferramenta personalizada"
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Nome da Ferramenta",
|
||||
"PLACEHOLDER": "Ex: Busca de Pedidos",
|
||||
"ERROR": "O nome da ferramenta é obrigatório"
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Nome da ferramente obrigatória"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Descrição",
|
||||
"PLACEHOLDER": "Descreva o que a ferramenta faz (ex: Busca detalhes do pedido pelo ID)"
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Método HTTP"
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "URL do Endpoint",
|
||||
"PLACEHOLDER": "https://api.exemplo.com/pedidos/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Uma URL válida é obrigatória"
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Tipo de Autenticação"
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "Nenhuma",
|
||||
"BEARER": "Token Bearer",
|
||||
"BASIC": "Autenticação Básica (Basic Auth)",
|
||||
"API_KEY": "Chave de API",
|
||||
"CUSTOM_HEADERS": "Cabeçalhos Personalizados"
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "Chave API"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Token Bearer",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Digite seu token bearer",
|
||||
"USERNAME": "Usuário",
|
||||
"USERNAME_PLACEHOLDER": "Digite o usuário",
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "Senha",
|
||||
"PASSWORD_PLACEHOLDER": "Digite a senha",
|
||||
"API_KEY": "Nome do Cabeçalho",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Valor do Cabeçalho",
|
||||
"API_VALUE_PLACEHOLDER": "Digite o valor da chave de API",
|
||||
"CUSTOM_HEADERS": "Cabeçalhos Personalizados",
|
||||
"CUSTOM_HEADERS_HELP": "Adicione um ou mais cabeçalhos HTTP enviados em cada solicitação (ex: IDs de API, tokens).",
|
||||
"HEADER_NAME": "Nome do Cabeçalho",
|
||||
"HEADER_NAME_PLACEHOLDER": "ex: PLUG-PLAY-ID",
|
||||
"HEADER_VALUE": "Valor do Cabeçalho",
|
||||
"HEADER_VALUE_PLACEHOLDER": "ex: 198",
|
||||
"ADD_HEADER": "Adicionar Cabeçalho"
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parâmetros",
|
||||
"HELP_TEXT": "Defina os parâmetros que serão extraídos das perguntas dos usuários"
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Adicionar Parâmetro",
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Nome do parâmetro (ex: order_id)"
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "Tipo"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "Texto (String)",
|
||||
"STRING": "String",
|
||||
"NUMBER": "Número",
|
||||
"BOOLEAN": "Booleano",
|
||||
"ARRAY": "Lista (Array)",
|
||||
"OBJECT": "Objeto"
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Descrição do parâmetro"
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "Obrigatório"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Modelo do Corpo da Requisição (Opcional)",
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Modelo de Resposta (Opcional)",
|
||||
"PLACEHOLDER": "Status do pedido {'{{'} order_id {'}}'}: {'{{'} status {'}}'}"
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "O nome do parâmetro é obrigatório"
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "Perguntas Frequentes (FAQs)",
|
||||
"PENDING_FAQS": "FAQs Pendentes",
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Criar nova FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversação #{id}"
|
||||
@ -928,11 +904,11 @@
|
||||
"TITLE": "Excluir as Perguntas Frequentes?",
|
||||
"DESCRIPTION": "Tem certeza que deseja excluir as Perguntas Frequentes selecionadas? Esta ação não pode ser desfeita.",
|
||||
"CONFIRM": "Sim, excluir todas",
|
||||
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso",
|
||||
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso/",
|
||||
"ERROR_MESSAGE": "Ocorreu um erro ao excluir as Perguntas Frequentes, por favor tente novamente."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Tem certeza que deseja excluir a FAQ?",
|
||||
"TITLE": "Tem certeza que deseja excluir o FAQ?",
|
||||
"DESCRIPTION": "",
|
||||
"CONFIRM": "Sim, excluir",
|
||||
"SUCCESS_MESSAGE": "FAQ excluída com sucesso",
|
||||
@ -946,12 +922,12 @@
|
||||
"STATUS": {
|
||||
"TITLE": "Status",
|
||||
"PENDING": "Pendentes",
|
||||
"APPROVED": "Aprovadas",
|
||||
"APPROVED": "Aceito",
|
||||
"ALL": "Todos"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "O Capitão encontrou algumas FAQs que seus clientes estavam procurando.",
|
||||
"ACTION": "Clique aqui para revisar"
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Adicione uma pergunta e sua resposta correspondente à base de conhecimento e selecione o assistente ao qual deve estar associado.",
|
||||
"CREATE": {
|
||||
@ -966,13 +942,13 @@
|
||||
"ERROR": "Por favor, forneça uma pergunta válida."
|
||||
},
|
||||
"ANSWER": {
|
||||
"LABEL": "Resposta",
|
||||
"LABEL": "Responder",
|
||||
"PLACEHOLDER": "Digite a resposta aqui",
|
||||
"ERROR": "Por favor, forneça uma resposta válida."
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Atualizar a FAQ",
|
||||
"TITLE": "Atualizar as Perguntas Frequentes",
|
||||
"SUCCESS_MESSAGE": "As Perguntas Frequentes foram atualizadas com sucesso",
|
||||
"ERROR_MESSAGE": "Ocorreu um erro ao atualizar as Perguntas Frequentes, por favor tente novamente",
|
||||
"APPROVE_SUCCESS_MESSAGE": "As Perguntas Frequentes foram marcadas como aprovadas"
|
||||
@ -984,12 +960,12 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Nenhuma FAQ encontrada",
|
||||
"NO_PENDING_TITLE": "Não há mais FAQs pendentes para revisar",
|
||||
"SUBTITLE": "As Perguntas Frequentes ajudam seu assistente a fornecer respostas rápidas e precisas para as perguntas de seus clientes. Elas podem ser geradas automaticamente a partir do seu conteúdo ou adicionadas manualmente.",
|
||||
"CLEAR_SEARCH": "Limpar filtros ativos",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "Perguntas Frequentes ajudam seu assistente a fornecer respostas rápidas e precisas para perguntas de seus clientes. Eles podem ser gerados automaticamente a partir do seu conteúdo ou podem ser adicionados manualmente.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "FAQ do Capitão",
|
||||
"NOTE": "O Capitão FAQ detecta perguntas comuns dos clientes — sejam elas ausentes na sua base de conhecimento ou frequentemente perguntadas — e gera FAQs relevantes para melhorar o suporte. Você pode revisar cada sugestão e decidir se a aprova ou rejeita."
|
||||
"TITLE": "Captain FAQ",
|
||||
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1026,4 +1002,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,11 +36,11 @@
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolvidas pelo bot",
|
||||
"NAME": "Contagem de Resolução",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Transferidas para humano",
|
||||
"NAME": "Contagem de transferências",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
@ -269,37 +269,8 @@
|
||||
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
|
||||
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
|
||||
"ALL_INBOXES": "Todas as caixas",
|
||||
"SEARCH_INBOX": "Buscar caixa",
|
||||
"TABS": {
|
||||
"OVERVIEW": "Visão Geral",
|
||||
"LEADS": "Novas × Retorno"
|
||||
},
|
||||
"LEADS": {
|
||||
"TITLE": "Novas × Retorno",
|
||||
"INBOX_LABEL": "Caixa de entrada:",
|
||||
"EMPTY": "Sem conversas no período.",
|
||||
"TOTAL": "Total de conversas no período: {count}",
|
||||
"METRICS": {
|
||||
"NEW_LEADS": {
|
||||
"LABEL": "Leads novos",
|
||||
"INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes."
|
||||
},
|
||||
"RETURNING": {
|
||||
"LABEL": "Retorno",
|
||||
"INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h."
|
||||
},
|
||||
"OTHERS": {
|
||||
"LABEL": "Outras",
|
||||
"INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h."
|
||||
}
|
||||
},
|
||||
"CHART": {
|
||||
"NEW_LEADS": "Novas",
|
||||
"RETURNING": "Retorno",
|
||||
"OTHERS": "Outras"
|
||||
}
|
||||
},
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversas",
|
||||
@ -466,32 +437,20 @@
|
||||
"HEADER": "Relatórios do Bot",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "Conversas",
|
||||
"TOOLTIP": "Total de conversas atendidas pelo bot no período"
|
||||
"LABEL": "Nº de Conversas",
|
||||
"TOOLTIP": "Número total de conversas tratadas pelo bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Mensagens enviadas",
|
||||
"TOOLTIP": "Total de mensagens enviadas — inclui o bot E humanos (via Chatwoot ou eco do WhatsApp)"
|
||||
"LABEL": "Total de respostas",
|
||||
"TOOLTIP": "Número total de respostas enviadas pelo bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolvidas pelo bot %",
|
||||
"TOOLTIP": "Conversas que o bot resolveu sozinho (sem humano respondendo, via Chatwoot ou WhatsApp) ÷ total de conversas × 100"
|
||||
"LABEL": "Tempo de resolução",
|
||||
"TOOLTIP": "Número total de conversas resolvidas pelo bot / número total de conversas manipuladas pelo bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Transferidas pra humano %",
|
||||
"TOOLTIP": "Conversas transferidas pra humano (auto pela Jasmine + tomada manual) ÷ total de conversas × 100. Junto com a taxa de resolução fecha a engrenagem (o resto está aberto, em snooze ou abandonado)."
|
||||
},
|
||||
"BOT_RESOLUTIONS": {
|
||||
"LABEL": "Resolvidas pelo bot",
|
||||
"TOOLTIP": "Contagem absoluta: conversas que o bot fechou sozinho, sem humano respondendo (via Chatwoot ou WhatsApp)"
|
||||
},
|
||||
"AUTO_HANDOFFS": {
|
||||
"LABEL": "Transferência automática (Jasmine)",
|
||||
"TOOLTIP": "Conversas em que a Jasmine chamou bot_handoff! explicitamente — geralmente loop de ferramenta, timeout, limite de turnos ou intent do LLM classificado como handoff"
|
||||
},
|
||||
"MANUAL_TAKEOVERS": {
|
||||
"LABEL": "Tomada manual (agente)",
|
||||
"TOOLTIP": "Conversas em que um humano respondeu (Chatwoot ou eco do WhatsApp) SEM a Jasmine ter chamado bot_handoff! antes — o agente assumiu silenciosamente"
|
||||
"LABEL": "Taxa de entrega",
|
||||
"TOOLTIP": "Número total de conversas entregues a agentes / número total de conversas mantidas pelo bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -619,47 +578,5 @@
|
||||
"AVG_REPLY_TIME": "Tempo Médio de Espera do Cliente",
|
||||
"RESOLUTION_COUNT": "Contagem de Resolução",
|
||||
"CONVERSATIONS": "Nº de Conversas"
|
||||
},
|
||||
"DIRECTORY_DASHBOARD": {
|
||||
"HEADER": "Painel Diretoria",
|
||||
"BANNER": {
|
||||
"TITLE": "Adoção do canal digital — não é a operação completa.",
|
||||
"BODY": "Esses números medem só o canal digital (Jasmine + reservas via app). Conversas atendidas manualmente que fecharam na recepção ainda não estão capturadas (marcação manual em construção)."
|
||||
},
|
||||
"HEADLINE_NUMBERS": "Números principais",
|
||||
"METRICS": {
|
||||
"LEADS_TOTAL": {
|
||||
"LABEL": "Leads (total)",
|
||||
"TOOLTIP": "Todas as conversas criadas no período (novos + retorno)"
|
||||
},
|
||||
"LEADS_NEW": {
|
||||
"LABEL": "Leads novos",
|
||||
"TOOLTIP": "Primeira conversa do contato em qualquer caixa da rede"
|
||||
},
|
||||
"LEADS_RETURNING": {
|
||||
"LABEL": "Leads de retorno",
|
||||
"TOOLTIP": "Contato com pelo menos uma conversa anterior"
|
||||
},
|
||||
"CONVERSION_RATE": {
|
||||
"LABEL": "Lead → Reserva paga",
|
||||
"TOOLTIP": "Reservas pagas ÷ total de leads × 100. Proxy de adoção, não retrato da operação."
|
||||
}
|
||||
},
|
||||
"FUNNEL": {
|
||||
"TITLE": "Funil",
|
||||
"STAGE_LEADS": "Leads",
|
||||
"STAGE_RESERVATIONS": "Reservas criadas",
|
||||
"STAGE_PAID": "Pagas"
|
||||
},
|
||||
"BENCHMARK": {
|
||||
"TITLE": "Comparativo entre unidades por marca",
|
||||
"BRAND_AVG": "média da marca",
|
||||
"COL_INBOX": "Caixa de Entrada",
|
||||
"COL_LEADS": "Leads",
|
||||
"COL_CREATED": "Criadas",
|
||||
"COL_PAID": "Pagas",
|
||||
"COL_RATE": "Taxa conv.",
|
||||
"COL_VS_BRAND": "vs marca"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Alerta agressivo de conversa",
|
||||
"SECTION_NOTE": "Ativa banner, som e notificação do SO quando uma conversa é reaberta e a cada 5/15/28 minutos sem resposta. Só some quando VOCÊ responder. Desativa se quiser turno silencioso — mas a casa cai se esquecer.",
|
||||
"TITLE": "Receber alertas agressivos",
|
||||
"NOTE": "Se ligado, você recebe banner + som + notificação quando uma conversa é reaberta ou fica X minutos sem resposta. Só desliga se souber o que está fazendo."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Personalize a aparência do seu painel do Chatwoot.",
|
||||
@ -121,15 +115,6 @@
|
||||
"RESET_SUCCESS": "Token de acesso gerado novamente com sucesso",
|
||||
"RESET_ERROR": "Não foi possível regerar o token de acesso. Por favor, tente novamente"
|
||||
},
|
||||
"AGGRESSIVE_ALERT_SECTION": {
|
||||
"TITLE": "Alerta de conversa parada",
|
||||
"NOTE": "Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.",
|
||||
"DESCRIPTION": "Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.",
|
||||
"ENABLED": "Ativar alerta de conversa parada",
|
||||
"APPLY_TO_ALL": "Aplicar em todas as caixas de entrada",
|
||||
"INBOX_HINT": "Selecione as caixas onde você quer receber o alerta:",
|
||||
"NO_INBOXES": "Nenhuma caixa de entrada cadastrada."
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Alertas de áudio",
|
||||
"NOTE": "Habilitar notificações de áudio no painel para novas mensagens e conversas.",
|
||||
@ -357,10 +342,6 @@
|
||||
"CAPTAIN_PIX_UNITS": "Unidades Pix",
|
||||
"CAPTAIN_GALLERY": "Galeria",
|
||||
"CAPTAIN_RESERVATIONS": "Reservas",
|
||||
"CAPTAIN_ROLETA": "Roleta — Resgate",
|
||||
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
|
||||
"CAPTAIN_FUNNEL": "Funil de Conversão",
|
||||
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
|
||||
"CAPTAIN_REPORTS": "Relatórios IA",
|
||||
"HOME": "Principal",
|
||||
"AGENTS": "Agentes",
|
||||
@ -397,7 +378,6 @@
|
||||
"ONE_OFF": "Única",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Robôs",
|
||||
"REPORTS_DIRECTORY_DASHBOARD": "Painel Diretoria",
|
||||
"REPORTS_AGENT": "Agentes",
|
||||
"REPORTS_LABEL": "Etiquetas",
|
||||
"REPORTS_INBOX": "Caixa de Entrada",
|
||||
@ -838,5 +818,46 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Excluir",
|
||||
"CANCEL_BUTTON_LABEL": "Cancelar"
|
||||
}
|
||||
},
|
||||
"CAPTAIN_REPORTS": {
|
||||
"TITLE": "Relatórios IA",
|
||||
"DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.",
|
||||
"LOADING": "Carregando relatórios...",
|
||||
"ALL_UNITS": "Todas as unidades",
|
||||
"ALL_INBOXES": "Todas as caixas de entrada",
|
||||
"TABS": {
|
||||
"INSIGHTS": "Insights IA",
|
||||
"OPERATIONAL": "Operacional"
|
||||
},
|
||||
"UNITS_GROUP": "Unidades Pix",
|
||||
"INBOXES_GROUP": "Caixas de Entrada",
|
||||
"STATUS": {
|
||||
"PENDING": "Pendente",
|
||||
"PROCESSING": "Processando",
|
||||
"DONE": "Concluído",
|
||||
"FAILED": "Falhou"
|
||||
},
|
||||
"GENERATE": {
|
||||
"BUTTON": "Gerar Análise",
|
||||
"SUCCESS": "Análise enfileirada com sucesso! Em breve estará disponível.",
|
||||
"ERROR": "Erro ao solicitar análise. Tente novamente."
|
||||
},
|
||||
"INSIGHT": {
|
||||
"CONVERSATIONS": "conversas",
|
||||
"MESSAGES": "mensagens",
|
||||
"TOP_TOPICS": "Principais Tópicos",
|
||||
"AI_FAILURES": "Pontos de Melhoria da IA",
|
||||
"COUNT_PREFIX": "(",
|
||||
"COUNT_SUFFIX": ")",
|
||||
"BULLET": "•"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE": "Nenhuma análise disponível",
|
||||
"MESSAGE": "Clique em Gerar Análise para criar o primeiro relatório semanal com insights das conversas."
|
||||
},
|
||||
"OPERATIONAL": {
|
||||
"COMING_SOON": "Em breve",
|
||||
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import { mapGetters } from 'vuex';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||
@ -18,12 +17,9 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const FAQ_QUESTION_MAX_LENGTH = 255;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddCannedModal,
|
||||
CreateResponseDialog,
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
@ -64,7 +60,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isCannedResponseModalOpen: false,
|
||||
isFaqModalOpen: false,
|
||||
showDeleteModal: false,
|
||||
showEditModal: false,
|
||||
editedContent: '',
|
||||
@ -76,7 +71,6 @@ export default {
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
getUISettings: 'getUISettings',
|
||||
copilotAssistant: 'getCopilotAssistant',
|
||||
}),
|
||||
plainTextContent() {
|
||||
return this.getPlainText(this.messageContent);
|
||||
@ -95,11 +89,6 @@ export default {
|
||||
this.message.content_attributes ?? this.message.contentAttributes
|
||||
);
|
||||
},
|
||||
faqQuestion() {
|
||||
return (this.plainTextContent || '')
|
||||
.trim()
|
||||
.slice(0, FAQ_QUESTION_MAX_LENGTH);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleEnterKey(e) {
|
||||
@ -136,29 +125,10 @@ export default {
|
||||
showCannedResponseModal() {
|
||||
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
|
||||
this.isCannedResponseModalOpen = true;
|
||||
this.handleClose();
|
||||
},
|
||||
hideCannedResponseModal() {
|
||||
this.isCannedResponseModalOpen = false;
|
||||
},
|
||||
async showFaqModal() {
|
||||
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'getInboxCaptainAssistantById',
|
||||
this.conversationId
|
||||
);
|
||||
} catch (error) {
|
||||
// Silence error, we can still open the modal
|
||||
}
|
||||
this.isFaqModalOpen = true;
|
||||
this.handleClose();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.faqDialog?.dialogRef?.open();
|
||||
});
|
||||
},
|
||||
hideFaqModal() {
|
||||
this.isFaqModalOpen = false;
|
||||
},
|
||||
handleOpen(e) {
|
||||
this.$emit('open', e);
|
||||
@ -193,7 +163,7 @@ export default {
|
||||
messageId: this.messageId,
|
||||
});
|
||||
useAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
|
||||
this.showDeleteModal = false;
|
||||
this.handleClose();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
|
||||
}
|
||||
@ -243,7 +213,7 @@ export default {
|
||||
<div class="context-menu">
|
||||
<!-- Add To Canned Responses -->
|
||||
<woot-modal
|
||||
v-if="isCannedResponseModalOpen"
|
||||
v-if="isCannedResponseModalOpen && enabledOptions['cannedResponse']"
|
||||
v-model:show="isCannedResponseModalOpen"
|
||||
:on-close="hideCannedResponseModal"
|
||||
>
|
||||
@ -252,20 +222,9 @@ export default {
|
||||
:on-close="hideCannedResponseModal"
|
||||
/>
|
||||
</woot-modal>
|
||||
<!-- Add To FAQ -->
|
||||
<CreateResponseDialog
|
||||
v-if="isFaqModalOpen"
|
||||
ref="faqDialog"
|
||||
type="create"
|
||||
:selected-response="{
|
||||
question: faqQuestion,
|
||||
assistant_id: copilotAssistant?.id,
|
||||
}"
|
||||
@close="hideFaqModal"
|
||||
/>
|
||||
<!-- Confirm Deletion -->
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
v-if="showDeleteModal && enabledOptions['delete']"
|
||||
v-model:show="showDeleteModal"
|
||||
class="context-menu--delete-modal"
|
||||
:on-close="closeDeleteModal"
|
||||
@ -321,7 +280,6 @@ export default {
|
||||
</form>
|
||||
</div>
|
||||
</woot-modal>
|
||||
|
||||
<NextButton
|
||||
v-if="!hideButton"
|
||||
ghost
|
||||
@ -331,9 +289,8 @@ export default {
|
||||
class="invisible group-hover/context-menu:visible"
|
||||
@click="handleOpen"
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
v-if="isOpen"
|
||||
v-if="isOpen && !isCannedResponseModalOpen"
|
||||
:x="contextMenuPosition.x"
|
||||
:y="contextMenuPosition.y"
|
||||
@close="handleClose"
|
||||
@ -380,10 +337,10 @@ export default {
|
||||
v-if="enabledOptions['cannedResponse']"
|
||||
:option="{
|
||||
icon: 'comment-add',
|
||||
label: 'Criar FAQ',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.CREATE_A_CANNED_RESPONSE'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="showFaqModal"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['edit']"
|
||||
|
||||
@ -284,7 +284,6 @@ onMounted(() => {
|
||||
:key="scenario.id"
|
||||
:title="scenario.title"
|
||||
:description="scenario.description"
|
||||
:trigger-keywords="scenario.trigger_keywords"
|
||||
:instruction="scenario.instruction"
|
||||
:tools="scenario.tools"
|
||||
:is-selected="bulkSelectedIds.has(scenario.id)"
|
||||
|
||||
@ -1,362 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const messages = ref([]);
|
||||
const input = ref('');
|
||||
const sending = ref(false);
|
||||
const polling = ref(null);
|
||||
const scrollContainer = ref(null);
|
||||
const sessionId = ref(null);
|
||||
|
||||
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
|
||||
const isWaiting = computed(
|
||||
() => sending.value || lastMessageRole.value === 'user'
|
||||
);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollContainer.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.fetchMessages();
|
||||
messages.value = data.messages || [];
|
||||
sessionId.value = data.session_id;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
// silencioso — polling repete
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.value.trim();
|
||||
if (!text || sending.value) return;
|
||||
sending.value = true;
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
input.value = '';
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
try {
|
||||
await hermesBuilderApi.sendMessage(text);
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const resetSession = async () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
|
||||
try {
|
||||
await hermesBuilderApi.reset();
|
||||
messages.value = [];
|
||||
} catch (e) {
|
||||
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
if (sending.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
await hermesBuilderApi.start();
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = iso => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchMessages();
|
||||
polling.value = setInterval(fetchMessages, 2000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (polling.value) clearInterval(polling.value);
|
||||
});
|
||||
|
||||
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="builder-wrapper">
|
||||
<header class="builder-header">
|
||||
<div>
|
||||
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" @click="resetSession">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section ref="scrollContainer" class="messages">
|
||||
<div v-if="!messages.length" class="empty-state">
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="start-button"
|
||||
:disabled="sending"
|
||||
@click="startSession"
|
||||
>
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="msg"
|
||||
:class="[`msg--${msg.role}`]"
|
||||
>
|
||||
<div class="msg__bubble">
|
||||
<div class="msg__content">{{ msg.content }}</div>
|
||||
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isWaiting" class="msg msg--construtor">
|
||||
<div class="msg__bubble msg__bubble--typing">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="composer">
|
||||
<textarea
|
||||
v-model="input"
|
||||
rows="2"
|
||||
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
|
||||
:disabled="sending"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
:disabled="!input.trim() || sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
<p v-if="sessionId" class="session-debug">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.builder-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 260px);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-background-light, #f7f8fa);
|
||||
border-radius: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: auto;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.start-button {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-woot-600, #1976d2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
|
||||
&--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&--construtor {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.msg__bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-background-light, #f3f4f6);
|
||||
font-size: 14px;
|
||||
|
||||
.msg--user & {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.msg__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg__meta {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.msg__bubble--typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-light, #6b7280);
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.session-debug {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,443 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const assistants = ref([]);
|
||||
const selectedSlug = ref('');
|
||||
const checks = ref([]);
|
||||
const summary = ref(null);
|
||||
const loading = ref(false);
|
||||
const repairing = ref({});
|
||||
|
||||
const groupedChecks = computed(() => {
|
||||
const groups = {};
|
||||
checks.value.forEach(c => {
|
||||
const cat = c.category || 'outros';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
const categoryLabel = cat => {
|
||||
const map = {
|
||||
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
|
||||
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
|
||||
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
|
||||
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
|
||||
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
|
||||
};
|
||||
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
|
||||
};
|
||||
|
||||
const fetchAssistants = async () => {
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.fetchAssistants();
|
||||
assistants.value = data.assistants || [];
|
||||
if (assistants.value.length && !selectedSlug.value) {
|
||||
selectedSlug.value = assistants.value[0].slug;
|
||||
}
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
|
||||
message: e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const runValidation = async () => {
|
||||
if (!selectedSlug.value || loading.value) return;
|
||||
loading.value = true;
|
||||
checks.value = [];
|
||||
summary.value = null;
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
|
||||
checks.value = data.results || [];
|
||||
summary.value = data;
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runRepair = async check => {
|
||||
if (!check.repair_id) return;
|
||||
repairing.value[check.repair_id] = true;
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.repair(
|
||||
selectedSlug.value,
|
||||
check.repair_id
|
||||
);
|
||||
if (data.ok) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
|
||||
message: data.message || 'OK',
|
||||
})
|
||||
);
|
||||
await runValidation();
|
||||
} else {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||
message: data.error || 'unknown',
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
repairing.value[check.repair_id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = status => {
|
||||
if (status === 'PASS') return '✓';
|
||||
if (status === 'FAIL') return '✗';
|
||||
if (status === 'WARN') return '⚠';
|
||||
return '?';
|
||||
};
|
||||
|
||||
const statusClass = status => {
|
||||
if (status === 'PASS') return 'badge--pass';
|
||||
if (status === 'FAIL') return 'badge--fail';
|
||||
if (status === 'WARN') return 'badge--warn';
|
||||
return 'badge--unknown';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAssistants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="verification-wrapper">
|
||||
<header class="verification-header">
|
||||
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
v-model="selectedSlug"
|
||||
class="select"
|
||||
:disabled="!assistants.length || loading"
|
||||
>
|
||||
<option v-if="!assistants.length" value="">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
|
||||
</option>
|
||||
<option v-for="a in assistants" :key="a.id" :value="a.slug">
|
||||
{{ a.name }} — {{ a.slug }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
:disabled="!selectedSlug || loading"
|
||||
@click="runValidation"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
|
||||
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="summary">
|
||||
<span class="summary__item summary__item--pass">
|
||||
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
|
||||
</span>
|
||||
<span v-if="summary.fail" class="summary__item summary__item--fail">
|
||||
{{ summary.fail }}
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
|
||||
</span>
|
||||
<span v-if="summary.warn" class="summary__item summary__item--warn">
|
||||
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
|
||||
</span>
|
||||
<span class="summary__total">
|
||||
{{
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
|
||||
}}
|
||||
</span>
|
||||
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
|
||||
✅ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
|
||||
</span>
|
||||
<span v-else class="summary__verdict summary__verdict--fail">
|
||||
❌ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section v-if="checks.length" class="checks-section">
|
||||
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
|
||||
<h3 class="check-group__title">
|
||||
{{ categoryLabel(cat) }}
|
||||
</h3>
|
||||
<ul class="check-list">
|
||||
<li
|
||||
v-for="(check, idx) in items"
|
||||
:key="idx"
|
||||
class="check-item"
|
||||
:class="`check-item--${check.status.toLowerCase()}`"
|
||||
>
|
||||
<span class="check-item__badge" :class="statusClass(check.status)">
|
||||
{{ statusIcon(check.status) }}
|
||||
</span>
|
||||
<div class="check-item__body">
|
||||
<div class="check-item__label">{{ check.label }}</div>
|
||||
<div v-if="check.detail" class="check-item__detail">
|
||||
{{ check.detail }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="
|
||||
check.repair_id &&
|
||||
(check.status === 'FAIL' || check.status === 'WARN')
|
||||
"
|
||||
type="button"
|
||||
class="repair-btn"
|
||||
:disabled="repairing[check.repair_id]"
|
||||
@click="runRepair(check)"
|
||||
>
|
||||
{{
|
||||
repairing[check.repair_id]
|
||||
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
|
||||
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
|
||||
}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-else-if="!loading && summary" class="empty-state">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
|
||||
</p>
|
||||
<p v-else-if="!loading" class="empty-state">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.verification-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.verification-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--color-background-light, #f7f8fa);
|
||||
border-radius: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.select {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-background, #fff);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-woot-500, #1f93ff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__item {
|
||||
font-weight: 600;
|
||||
|
||||
&--pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
&--fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
&--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&__total {
|
||||
color: var(--color-text-light, #6b7280);
|
||||
}
|
||||
|
||||
&__verdict {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
|
||||
&--pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
&--fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checks-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.check-group {
|
||||
background: var(--color-background, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.check-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&--fail {
|
||||
background: #fef2f2;
|
||||
}
|
||||
&--warn {
|
||||
background: #fffbeb;
|
||||
}
|
||||
}
|
||||
|
||||
.check-item__badge {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
&.badge--pass {
|
||||
background: #16a34a;
|
||||
}
|
||||
&.badge--fail {
|
||||
background: #dc2626;
|
||||
}
|
||||
&.badge--warn {
|
||||
background: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.check-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.check-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check-item__detail {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.repair-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-woot-500, #1f93ff);
|
||||
background: var(--color-background, #fff);
|
||||
color: var(--color-woot-500, #1f93ff);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
font-size: 14px;
|
||||
padding: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import BuilderChat from './BuilderChat.vue';
|
||||
import BuilderVerification from './BuilderVerification.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
|
||||
]);
|
||||
|
||||
const activeIndex = ref(0);
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
|
||||
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
|
||||
>
|
||||
<div class="builder-tabs">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
</div>
|
||||
<div class="builder-panels">
|
||||
<BuilderChat v-show="activeIndex === 0" />
|
||||
<BuilderVerification v-show="activeIndex === 1" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.builder-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.builder-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,5 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
@ -18,13 +17,6 @@ import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
import ReservationsIndex from './reservations/Index.vue';
|
||||
import RoletaIndex from './roleta/Index.vue';
|
||||
import HermesBuilderIndex from './builder/Index.vue';
|
||||
import FunnelIndex from './funnel/Index.vue';
|
||||
import LifecycleIndex from './lifecycle/Index.vue';
|
||||
import LifecycleRules from './lifecycle/Rules.vue';
|
||||
import LifecycleSettings from './lifecycle/Settings.vue';
|
||||
import LifecycleHistory from './lifecycle/History.vue';
|
||||
|
||||
const meta = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
@ -32,11 +24,6 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const knowledgeBaseMeta = {
|
||||
...meta,
|
||||
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
|
||||
};
|
||||
|
||||
const metaV2 = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||
@ -48,13 +35,13 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
||||
component: ResponsesIndex,
|
||||
name: 'captain_assistants_responses_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
|
||||
component: DocumentsIndex,
|
||||
name: 'captain_assistants_documents_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
@ -84,7 +71,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
@ -125,7 +112,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
|
||||
component: AssistantsIndexPage,
|
||||
name: 'captain_assistants_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
];
|
||||
|
||||
@ -150,55 +137,4 @@ export const routes = [
|
||||
name: 'captain_reservations_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/roleta'),
|
||||
component: RoletaIndex,
|
||||
name: 'captain_roleta_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
|
||||
component: HermesBuilderIndex,
|
||||
name: 'captain_hermes_builder_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/funnel'),
|
||||
component: FunnelIndex,
|
||||
name: 'captain_funnel_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/lifecycle'),
|
||||
component: LifecycleIndex,
|
||||
meta,
|
||||
redirect: { name: 'captain_lifecycle_rules' },
|
||||
children: [
|
||||
{
|
||||
path: 'rules',
|
||||
component: LifecycleRules,
|
||||
name: 'captain_lifecycle_rules',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
component: LifecycleSettings,
|
||||
name: 'captain_lifecycle_settings',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
component: LifecycleHistory,
|
||||
name: 'captain_lifecycle_history',
|
||||
meta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import funnelApi from 'dashboard/api/captain/funnel';
|
||||
|
||||
const { t } = useI18n();
|
||||
const periodDays = ref(30);
|
||||
const report = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const SUITE_ORDER = ['Alexa', 'Stilo', 'Hidromassagem'];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await funnelApi.get(periodDays.value);
|
||||
report.value = data;
|
||||
} catch (err) {
|
||||
useAlert(t('CAPTAIN_FUNNEL.LOAD_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const byDataSuite = computed(() => {
|
||||
if (!report.value?.by_suite) return [];
|
||||
return SUITE_ORDER.filter(s => report.value.by_suite[s]).map(s => ({
|
||||
name: s,
|
||||
stages: report.value.by_suite[s],
|
||||
}));
|
||||
});
|
||||
|
||||
const topDropOff = computed(() => {
|
||||
const d = report.value?.top_drop_off;
|
||||
if (!d) return null;
|
||||
return {
|
||||
...d,
|
||||
from_label: t(`CAPTAIN_FUNNEL.STAGES.${d.from}`),
|
||||
to_label: t(`CAPTAIN_FUNNEL.STAGES.${d.to}`),
|
||||
};
|
||||
});
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function barWidth(count, maxCount) {
|
||||
if (!maxCount) return '0%';
|
||||
return `${Math.max(4, (count / maxCount) * 100)}%`;
|
||||
}
|
||||
|
||||
function stageLabel(key) {
|
||||
return t(`CAPTAIN_FUNNEL.STAGES.${key}`);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="t('CAPTAIN_FUNNEL.HEADER')"
|
||||
:show-assistant-switcher="false"
|
||||
:show-pagination-footer="false"
|
||||
:is-empty="false"
|
||||
:is-fetching="false"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6 py-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm text-n-slate-11 max-w-xl">
|
||||
{{ t('CAPTAIN_FUNNEL.DESC') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model.number="periodDays"
|
||||
class="rounded-md border border-n-weak bg-transparent text-sm px-2 py-1"
|
||||
@change="load"
|
||||
>
|
||||
<option :value="7">{{ t('CAPTAIN_FUNNEL.PERIOD_7') }}</option>
|
||||
<option :value="30">{{ t('CAPTAIN_FUNNEL.PERIOD_30') }}</option>
|
||||
<option :value="60">{{ t('CAPTAIN_FUNNEL.PERIOD_60') }}</option>
|
||||
<option :value="90">{{ t('CAPTAIN_FUNNEL.PERIOD_90') }}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-refresh-cw"
|
||||
size="xs"
|
||||
:is-loading="loading"
|
||||
@click="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-n-slate-11 py-6">
|
||||
{{ t('CAPTAIN_FUNNEL.LOADING') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="report && report.total_conversations_analyzed > 0">
|
||||
<div
|
||||
v-if="topDropOff && topDropOff.lost > 0"
|
||||
class="rounded-xl border border-n-amber-7 bg-n-amber-3 p-4"
|
||||
>
|
||||
<div class="text-xs uppercase tracking-wide text-n-amber-11 mb-1">
|
||||
{{ t('CAPTAIN_FUNNEL.INSIGHT_LABEL') }}
|
||||
</div>
|
||||
<div class="text-n-amber-12">
|
||||
{{
|
||||
t('CAPTAIN_FUNNEL.INSIGHT_FULL', {
|
||||
lost: topDropOff.lost,
|
||||
from: topDropOff.from_label,
|
||||
to: topDropOff.to_label,
|
||||
pct: fmtPct(topDropOff.drop_pct),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-n-slate-12 mb-4">
|
||||
{{
|
||||
t('CAPTAIN_FUNNEL.FUNNEL_TITLE').replace(
|
||||
'{count}',
|
||||
report.total_conversations_analyzed
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(stage, idx) in report.funnel"
|
||||
:key="stage.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="w-40 text-sm text-n-slate-11">
|
||||
{{ stageLabel(stage.key) }}
|
||||
</div>
|
||||
<div class="flex-1 relative h-8 bg-n-alpha-black1 rounded">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full rounded bg-gradient-to-r from-n-brand/70 to-n-brand transition-all"
|
||||
:style="{
|
||||
width: barWidth(stage.count, report.funnel[0].count),
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="relative h-full flex items-center px-3 text-sm font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ stage.count }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-20 text-right text-xs text-n-slate-11">
|
||||
<template v-if="idx > 0">
|
||||
{{ fmtPct(stage.conversion) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="byDataSuite.length > 0"
|
||||
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-n-slate-12 mb-4">
|
||||
{{ t('CAPTAIN_FUNNEL.BY_SUITE_TITLE') }}
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs text-n-slate-11 uppercase tracking-wide">
|
||||
<tr class="border-b border-n-weak">
|
||||
<th class="text-left py-2 px-2">
|
||||
{{ t('CAPTAIN_FUNNEL.BY_SUITE_HEADER') }}
|
||||
</th>
|
||||
<th
|
||||
v-for="stage in report.funnel"
|
||||
:key="stage.key"
|
||||
class="text-right py-2 px-2"
|
||||
>
|
||||
{{ stageLabel(stage.key) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in byDataSuite"
|
||||
:key="s.name"
|
||||
class="border-b border-n-weak last:border-b-0"
|
||||
>
|
||||
<td class="py-2 px-2 font-medium text-n-slate-12">
|
||||
{{ s.name }}
|
||||
</td>
|
||||
<td
|
||||
v-for="stage in s.stages"
|
||||
:key="stage.key"
|
||||
class="py-2 px-2 text-right text-n-slate-11"
|
||||
>
|
||||
{{ stage.count }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 mt-3">
|
||||
{{ t('CAPTAIN_FUNNEL.BY_SUITE_FOOTER') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="report"
|
||||
class="text-sm text-n-slate-11 py-6 text-center"
|
||||
>
|
||||
{{ t('CAPTAIN_FUNNEL.EMPTY') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@ -1,135 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import DeliveryPreviewModal from './components/DeliveryPreviewModal.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deliveries = useMapGetter('captainLifecycleDeliveries/getRecords');
|
||||
const meta = useMapGetter('captainLifecycleDeliveries/getMeta');
|
||||
const uiFlags = useMapGetter('captainLifecycleDeliveries/getUIFlags');
|
||||
|
||||
const status = ref('');
|
||||
const page = ref(1);
|
||||
const selectedDelivery = ref(null);
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', key: 'ALL' },
|
||||
{ value: 'scheduled', key: 'SCHEDULED' },
|
||||
{ value: 'sent', key: 'SENT' },
|
||||
{ value: 'skipped', key: 'SKIPPED' },
|
||||
{ value: 'failed', key: 'FAILED' },
|
||||
{ value: 'cancelled', key: 'CANCELLED' },
|
||||
];
|
||||
|
||||
const fetchDeliveries = () => {
|
||||
store.dispatch('captainLifecycleDeliveries/get', {
|
||||
page: page.value,
|
||||
...(status.value ? { status: status.value } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(fetchDeliveries);
|
||||
watch([status, page], fetchDeliveries);
|
||||
|
||||
const isLoading = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.total_count || 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.STATUS') }}:
|
||||
<select v-model="status" class="border rounded px-2 py-1">
|
||||
<option
|
||||
v-for="opt in STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{
|
||||
opt.value
|
||||
? t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${opt.key}`)
|
||||
: t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.ALL')
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ totalCount }} {{ t('CAPTAIN_LIFECYCLE.HISTORY.TOTAL') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="deliveries.length === 0"
|
||||
class="text-center py-8 text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.EMPTY') }}
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left text-n-slate-11">
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RULE') }}
|
||||
</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.CUSTOMER') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RESERVATION') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.STATUS') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.FIRE_AT') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.REASON') }}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="d in deliveries"
|
||||
:key="d.id"
|
||||
class="border-t border-n-slate-4"
|
||||
>
|
||||
<td class="py-2">{{ d.lifecycle_rule_name || '—' }}</td>
|
||||
<td>{{ d.reservation?.customer_name || '—' }}</td>
|
||||
<td>
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RESERVATION_ID')
|
||||
}}{{ d.captain_reservation_id }}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${d.status.toUpperCase()}`)
|
||||
}}
|
||||
</td>
|
||||
<td>{{ new Date(d.fire_at).toLocaleString('pt-BR') }}</td>
|
||||
<td>{{ d.skip_reason || d.failure_reason || '' }}</td>
|
||||
<td>
|
||||
<Button size="sm" variant="ghost" @click="selectedDelivery = d">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PREVIEW') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex justify-center gap-2 mt-4">
|
||||
<Button :disabled="page <= 1" @click="page -= 1">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PAGINATION.PREV') }}
|
||||
</Button>
|
||||
<span class="text-sm self-center">{{ page }}</span>
|
||||
<Button :disabled="deliveries.length < 25" @click="page += 1">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PAGINATION.NEXT') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DeliveryPreviewModal
|
||||
:delivery="selectedDelivery"
|
||||
@close="selectedDelivery = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'captain_lifecycle_rules', label: t('CAPTAIN_LIFECYCLE.TABS.RULES') },
|
||||
{
|
||||
name: 'captain_lifecycle_settings',
|
||||
label: t('CAPTAIN_LIFECYCLE.TABS.SETTINGS'),
|
||||
},
|
||||
{
|
||||
name: 'captain_lifecycle_history',
|
||||
label: t('CAPTAIN_LIFECYCLE.TABS.HISTORY'),
|
||||
},
|
||||
]);
|
||||
|
||||
const activeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
tabs.value.findIndex(tab => tab.name === route.name)
|
||||
)
|
||||
);
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
router.push({ name: tab.name, params: route.params });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="t('CAPTAIN_LIFECYCLE.HEADER')"
|
||||
:show-assistant-switcher="false"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
<router-view />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import RuleWizardDialog from './components/RuleWizardDialog.vue';
|
||||
import { RULE_TEMPLATES } from './constants';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const rules = useMapGetter('captainLifecycleRules/getRecords');
|
||||
const uiFlags = useMapGetter('captainLifecycleRules/getUIFlags');
|
||||
|
||||
const showWizard = ref(false);
|
||||
const editing = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainLifecycleRules/get');
|
||||
store.dispatch('captainUnits/get');
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
editing.value = null;
|
||||
showWizard.value = true;
|
||||
};
|
||||
const openEdit = rule => {
|
||||
editing.value = rule;
|
||||
showWizard.value = true;
|
||||
};
|
||||
const openFromTemplate = tpl => {
|
||||
editing.value = {
|
||||
id: null,
|
||||
name: tpl.name,
|
||||
event: tpl.event,
|
||||
offset_minutes: tpl.offset_minutes,
|
||||
message_type: tpl.message_type,
|
||||
message_body: tpl.message_body,
|
||||
enabled: true,
|
||||
filters: {},
|
||||
priority: 50,
|
||||
};
|
||||
showWizard.value = true;
|
||||
};
|
||||
const onSaved = () => {
|
||||
showWizard.value = false;
|
||||
store.dispatch('captainLifecycleRules/get');
|
||||
};
|
||||
const toggle = async rule => {
|
||||
await store.dispatch('captainLifecycleRules/update', {
|
||||
id: rule.id,
|
||||
enabled: !rule.enabled,
|
||||
});
|
||||
};
|
||||
const remove = async rule => {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(t('CAPTAIN_LIFECYCLE.RULES.DELETE_CONFIRM'))) return;
|
||||
await store.dispatch('captainLifecycleRules/delete', rule.id);
|
||||
useAlert(t('CAPTAIN_LIFECYCLE.RULES.TOAST.DELETED'));
|
||||
};
|
||||
|
||||
const isLoading = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const formatOffset = offsetMinutes =>
|
||||
`${offsetMinutes >= 0 ? '+' : ''}${offsetMinutes}${t('CAPTAIN_LIFECYCLE.RULES.WIZARD.OFFSET_UNIT_LABEL')}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold mb-3 text-n-slate-11">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.TEMPLATES_TITLE') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="tpl in RULE_TEMPLATES"
|
||||
:key="tpl.id"
|
||||
type="button"
|
||||
class="text-left p-3 border border-n-slate-4 rounded-lg hover:border-n-iris-9"
|
||||
@click="openFromTemplate(tpl)"
|
||||
>
|
||||
<div class="font-medium text-sm">{{ tpl.name }}</div>
|
||||
<div class="text-xs text-n-slate-11 mt-1">
|
||||
{{ tpl.event }} {{ formatOffset(tpl.offset_minutes) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ t('CAPTAIN_LIFECYCLE.TABS.RULES') }}
|
||||
</h3>
|
||||
<Button @click="openCreate">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.CREATE') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading"><Spinner /></div>
|
||||
<div
|
||||
v-else-if="rules.length === 0"
|
||||
class="text-center py-8 text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.EMPTY') }}
|
||||
</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left text-n-slate-11">
|
||||
<tr>
|
||||
<th class="py-2">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.NAME') }}
|
||||
</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.EVENT') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.OFFSET') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.STATUS') }}</th>
|
||||
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.ACTIONS') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rules" :key="r.id" class="border-t border-n-slate-4">
|
||||
<td class="py-2">{{ r.name }}</td>
|
||||
<td>{{ r.event }}</td>
|
||||
<td>{{ formatOffset(r.offset_minutes) }}</td>
|
||||
<td>
|
||||
{{
|
||||
r.enabled
|
||||
? t('CAPTAIN_LIFECYCLE.RULES.STATUS.ENABLED')
|
||||
: t('CAPTAIN_LIFECYCLE.RULES.STATUS.DISABLED')
|
||||
}}
|
||||
</td>
|
||||
<td class="flex gap-2">
|
||||
<Button size="sm" variant="ghost" @click="openEdit(r)">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.EDIT') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" @click="toggle(r)">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.TOGGLE') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" @click="remove(r)">
|
||||
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.DELETE') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<RuleWizardDialog
|
||||
v-if="showWizard"
|
||||
:rule="editing"
|
||||
@close="showWizard = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,143 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConciergeUnitCard from './components/ConciergeUnitCard.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const config = useMapGetter('captainLifecycleConfig/getConfig');
|
||||
const uiFlags = useMapGetter('captainLifecycleConfig/getUIFlags');
|
||||
const units = useMapGetter('captainUnits/getUnits');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const form = ref({});
|
||||
|
||||
const syncForm = () => {
|
||||
form.value = { ...config.value };
|
||||
};
|
||||
|
||||
watch(config, syncForm, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainLifecycleConfig/fetch');
|
||||
store.dispatch('captainUnits/get');
|
||||
store.dispatch('labels/get');
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await store.dispatch('captainLifecycleConfig/update', {
|
||||
quiet_hours_enabled: form.value.quiet_hours_enabled,
|
||||
quiet_hours_from: form.value.quiet_hours_from,
|
||||
quiet_hours_to: form.value.quiet_hours_to,
|
||||
min_interval_minutes: Number(form.value.min_interval_minutes),
|
||||
pause_on_customer_reply: form.value.pause_on_customer_reply,
|
||||
pause_on_customer_reply_within_minutes: Number(
|
||||
form.value.pause_on_customer_reply_within_minutes
|
||||
),
|
||||
opt_out_label_id: form.value.opt_out_label_id || null,
|
||||
});
|
||||
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.SAVED'));
|
||||
} catch (e) {
|
||||
useAlert(e.message || 'Error');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-8">
|
||||
<section>
|
||||
<h3 class="text-base font-semibold mb-3">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.GUARDS_TITLE') }}
|
||||
</h3>
|
||||
<div v-if="uiFlags.fetching">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="space-y-3 max-w-xl">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox v-model="form.quiet_hours_enabled" />
|
||||
<span class="text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_ENABLED') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="form.quiet_hours_enabled" class="flex gap-3">
|
||||
<label class="flex-1 text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_FROM') }}
|
||||
<input
|
||||
v-model="form.quiet_hours_from"
|
||||
type="time"
|
||||
class="w-full border rounded px-2 py-1"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex-1 text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_TO') }}
|
||||
<input
|
||||
v-model="form.quiet_hours_to"
|
||||
type="time"
|
||||
class="w-full border rounded px-2 py-1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="form.min_interval_minutes"
|
||||
type="number"
|
||||
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL')"
|
||||
:message="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL_HELP')"
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox v-model="form.pause_on_customer_reply" />
|
||||
<span class="text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
v-if="form.pause_on_customer_reply"
|
||||
v-model="form.pause_on_customer_reply_within_minutes"
|
||||
type="number"
|
||||
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY_WINDOW')"
|
||||
/>
|
||||
|
||||
<label class="block text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.OPT_OUT_LABEL') }}
|
||||
<select
|
||||
v-model="form.opt_out_label_id"
|
||||
class="w-full border rounded px-2 py-1"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="label in labels" :key="label.id" :value="label.id">
|
||||
{{ label.title }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.MAX_PER_RESERVATION_INFO') }}
|
||||
</p>
|
||||
|
||||
<Button :disabled="uiFlags.updating" @click="save">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold mb-3">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_TITLE') }}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<ConciergeUnitCard v-for="u in units" :key="u.id" :unit="u" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,144 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CaptainUnitsAPI from 'dashboard/api/captain/units';
|
||||
|
||||
const props = defineProps({
|
||||
unit: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const inboxes = useMapGetter('inboxes/getWhatsAppInboxes');
|
||||
|
||||
const expanded = ref(false);
|
||||
const conciergeInboxId = ref(props.unit.concierge_inbox_id || null);
|
||||
const personaName = ref(props.unit.concierge_config?.persona_name || 'Sofia');
|
||||
const knowledge = ref(props.unit.concierge_config?.knowledge || '');
|
||||
const variables = ref(
|
||||
Object.entries(props.unit.concierge_config?.variables || {}).map(
|
||||
([k, v]) => ({
|
||||
k,
|
||||
v,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const addVariable = () => variables.value.push({ k: '', v: '' });
|
||||
const removeVariable = i => variables.value.splice(i, 1);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const varsObj = Object.fromEntries(
|
||||
variables.value.filter(x => x.k).map(x => [x.k, x.v])
|
||||
);
|
||||
await CaptainUnitsAPI.updateConcierge(props.unit.id, {
|
||||
concierge_inbox_id: conciergeInboxId.value,
|
||||
concierge_config: {
|
||||
persona_name: personaName.value,
|
||||
knowledge: knowledge.value,
|
||||
variables: varsObj,
|
||||
},
|
||||
});
|
||||
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.CONCIERGE_SAVED'));
|
||||
} catch (e) {
|
||||
useAlert(e.message || 'Error saving concierge');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-n-slate-4 rounded-lg p-4">
|
||||
<div
|
||||
class="flex justify-between items-center cursor-pointer"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">{{ unit.name }}</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{
|
||||
unit.concierge_inbox_id
|
||||
? t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_CONFIGURED')
|
||||
: t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_NOT_CONFIGURED')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ expanded ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="mt-4 space-y-3">
|
||||
<label class="block text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_INBOX') }}
|
||||
<select
|
||||
v-model="conciergeInboxId"
|
||||
class="w-full border rounded px-2 py-1"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="ib in inboxes" :key="ib.id" :value="ib.id">
|
||||
{{ ib.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
v-model="personaName"
|
||||
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_PERSONA')"
|
||||
/>
|
||||
|
||||
<label class="block text-sm">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_KNOWLEDGE') }}
|
||||
<textarea
|
||||
v-model="knowledge"
|
||||
rows="8"
|
||||
class="w-full border rounded p-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium mb-2">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLES') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(variable, i) in variables"
|
||||
:key="i"
|
||||
class="flex gap-2 mb-2"
|
||||
>
|
||||
<input
|
||||
v-model="variable.k"
|
||||
:placeholder="
|
||||
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_KEY')
|
||||
"
|
||||
class="border rounded px-2 py-1 w-1/3"
|
||||
/>
|
||||
<input
|
||||
v-model="variable.v"
|
||||
:placeholder="
|
||||
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_VALUE')
|
||||
"
|
||||
class="border rounded px-2 py-1 flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-x"
|
||||
@click="removeVariable(i)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-plus"
|
||||
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_ADD_VARIABLE')"
|
||||
@click="addVariable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="save">
|
||||
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
delivery: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="delivery"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-xl p-6 w-[560px] max-h-[80vh] overflow-auto shadow-xl"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.TITLE') }}
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RULE') }}:</strong>
|
||||
{{ delivery.lifecycle_rule_name || '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.STATUS') }}:</strong>
|
||||
{{ delivery.status }}
|
||||
</div>
|
||||
<div v-if="delivery.skip_reason">
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.REASON') }}:</strong>
|
||||
{{ delivery.skip_reason }}
|
||||
</div>
|
||||
<div v-if="delivery.failure_reason">
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.ERROR') }}:</strong>
|
||||
{{ delivery.failure_reason }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.FIRE_AT') }}:</strong>
|
||||
{{ delivery.fire_at }}
|
||||
</div>
|
||||
<div v-if="delivery.sent_at">
|
||||
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.SENT_AT') }}:</strong>
|
||||
{{ delivery.sent_at }}
|
||||
</div>
|
||||
<div>
|
||||
<strong
|
||||
>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RENDERED') }}:</strong
|
||||
>
|
||||
<pre class="mt-1 p-3 bg-n-alpha-2 rounded whitespace-pre-wrap">{{
|
||||
delivery.rendered_body || '—'
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<Button variant="outline" @click="emit('close')">
|
||||
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.CLOSE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user