feat(captain): semantic memory fixes + roleta + reclamações + analytics

Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra
testar em staging antes do merge pra main.

## Correções de memória semântica
- ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção).
- Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora).

## Roleta da Sorte (end-to-end)
- Schema Supabase + 7 RPCs atômicas (server-side, idempotentes).
- Services: Offer, Redeem, WeeklyReport.
- Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago),
  NotifyRevealed + Scheduler de fallback.
- Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify.
- Dashboard /captain/roleta com Resgate + Relatório + anomaly detection.

## Cenário Reclamacoes_Ouvidoria
- Triagem P1-P4, framework LAST, Three-level listening, Self-check.
- Sem compensação material, detecção de cliente frustrado eleva prioridade.

## Analytics
- Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM.
- Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT).

## Trabalho pré-existente incluído
- Captain Executive Reports (ceo_digest, mattermost_delivery).
- get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos.

## Outros
- .gitignore: patterns pra credenciais.
- Migrations de scenarios idempotentes.
- i18n completa pt_BR+en pra roleta/funnel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-21 15:36:25 -03:00
parent 978ccbbdfb
commit cfffea9c16
74 changed files with 9424 additions and 484 deletions

View File

@ -107,3 +107,8 @@ RESERVA_1001_API_TOKEN=
# Reserva Rede 1001 — URL base do app publico (usada pela Jasmine pra gerar links prefill) # Reserva Rede 1001 — URL base do app publico (usada pela Jasmine pra gerar links prefill)
RESERVA_1001_BASE_URL=http://localhost:5180 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
View File

@ -129,3 +129,10 @@ Thumbs.db
.env.aios .env.aios
.env.backup* .env.backup*
reference/chatwoot-develop reference/chatwoot-develop
# Credentials / secrets — NUNCA commitar
docs/acessos_vps.md
docs/acessos*.md
**/acessos_vps*
**/*_secrets.md
**/*.credentials

188
AGENTS.md
View File

@ -4,56 +4,49 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
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. **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.
**Tech Stack:** **Tech Stack:**
- Backend: Ruby 3.4.4 + Rails 7.1 - Backend: Ruby 3.4.4 + Rails 7.1
- Frontend: Vue 3 + Vite - Frontend: Vue 3 + Vite + Pinia
- Database: PostgreSQL with pgvector - Database: PostgreSQL + pgvector
- Background Jobs: Sidekiq - Background Jobs: Sidekiq (com sidekiq-cron)
- Package Manager: **pnpm** (required, not npm/yarn) - Package Manager: **pnpm** (obrigatório — nunca npm/yarn)
- Testing: RSpec (backend), Vitest (frontend) - Testing: RSpec (backend), Vitest (frontend)
- Event system: Wisper (pub/sub)
- Authorization: Pundit
## Development Commands ## Development Commands
### Starting the Application ### Iniciar aplicação
```bash ```bash
# Development server (Rails backend + Sidekiq + Vite) pnpm run dev # overmind: Rails :3000 + Sidekiq + Vite
pnpm run dev pnpm run start:dev # foreman (alternativo)
# Individual processes:
# - Rails backend: http://localhost:3001
# - Sidekiq: background worker
# - Vite: frontend dev server
``` ```
### Testing ### Testes
```bash ```bash
# Frontend (Vitest) - CRITICAL: NO -- flag with pnpm test! # Frontend (Vitest) — CRÍTICO: sem -- com pnpm!
pnpm test # Run all tests pnpm test # todos
pnpm test <file> # Run specific file (NOT pnpm test -- <file>) pnpm test app/javascript/path # arquivo específico (NÃO use pnpm test -- <file>)
pnpm test:watch # Watch mode pnpm test:watch
pnpm test:coverage # Coverage report pnpm test:coverage
# Backend (RSpec) # Backend (RSpec)
bundle exec rspec # All specs bundle exec rspec # todos
bundle exec rspec spec/models/user_spec.rb # Specific file bundle exec rspec spec/models/contact_spec.rb # arquivo
bundle exec rspec spec/models/user_spec.rb:42 # Specific line bundle exec rspec spec/models/contact_spec.rb:42 # linha específica
``` ```
### Code Quality ### Qualidade de código
```bash ```bash
# JavaScript/Vue linting pnpm run eslint # lint JS/Vue
pnpm run eslint # Check pnpm run eslint:fix # auto-fix
pnpm run eslint:fix # Auto-fix bundle exec rubocop # lint Ruby
bundle exec rubocop -a # auto-fix Ruby
# Ruby linting
bundle exec rubocop # Check
bundle exec rubocop -a # Auto-fix
pnpm run ruby:prettier # Same as rubocop -a
``` ```
### Database ### Database
@ -61,72 +54,102 @@ pnpm run ruby:prettier # Same as rubocop -a
```bash ```bash
bin/rails db:migrate bin/rails db:migrate
bin/rails db:rollback bin/rails db:rollback
bin/rails db:reset bin/rails db:reset && bin/rails db:seed
bin/rails db:seed
``` ```
## Architecture Overview ### i18n
### Backend Structure ```bash
pnpm run sync:i18n # sincroniza arquivo de tradução
```
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)
``` ```
**Key Patterns:** ## Arquitetura Backend
- **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
### Frontend Structure ### Modelo de dados central
```
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)
```
### Padrões Rails usados
| 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
``` ```
app/javascript/ app/javascript/
├── dashboard/ # Agent dashboard (Vue 3 + Vue Router + Vuex) ├── dashboard/ # Dashboard do agente (Vue 3 + Vue Router + Pinia)
│ ├── routes/ # Page components │ ├── routes/ # Componentes de página
│ ├── store/ # Vuex state │ ├── store/ # Pinia stores (55+ módulos: conversations, contacts, captain*)
│ ├── components/ # Reusable components │ ├── components/ # Componentes reutilizáveis
│ ├── api/ # API clients │ ├── api/ # Clientes HTTP por recurso
│ └── i18n/ # Translations (en, pt_BR required!) │ └── i18n/locale/ # Traduções (en + pt_BR SEMPRE)
├── widget/ # Customer chat widget ├── widget/ # Widget de chat embeddable
├── sdk/ # Embeddable JavaScript SDK ├── sdk/ # SDK JS (build separado: pnpm run build:sdk)
├── portal/ # Public help center ├── portal/ # Help center público
└── shared/ # Shared utilities └── shared/ # Utilities compartilhados
``` ```
**Vite Import Aliases:** **Aliases Vite:**
- `components``app/javascript/dashboard/components` - `components``app/javascript/dashboard/components`
- `dashboard``app/javascript/dashboard` - `dashboard``app/javascript/dashboard`
- `helpers``app/javascript/shared/helpers` - `helpers``app/javascript/shared/helpers`
- `shared`, `widget`, `survey`, `v3` → respective directories - `shared`, `widget`, `survey`, `v3`diretórios equivalentes
## Critical Conventions **Bibliotecas chave:** ProseMirror (rich text), ActionCable (real-time), Chart.js, Twilio Voice SDK
### fazer.ai Branding ## Convenções críticas
**ALWAYS** style as `fazer.ai` (lowercase with dot), **NEVER** `Fazer.ai` or `FAZER.AI`
### Internationalization ### Branding
**ALWAYS include pt_BR translations** for any new user-facing text `fazer.ai` — sempre minúsculo com ponto. Nunca `Fazer.ai` ou `FAZER.AI`.
- Location: `app/javascript/dashboard/i18n/locale/{en,pt_BR}/`
### Testing Philosophy ### Internacionalização
- Add specs when modifying code (use judgment) Qualquer texto visível ao usuário **exige** tradução em `en` e `pt_BR`:
- Test behavior, not implementation ```
- Consider cross-stack impacts (backend ↔ frontend) 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/`.
--- ---
@ -146,9 +169,10 @@ This repository includes **Synkra AIOS** - an AI-orchestrated development system
<!-- AIOS-MANAGED-START: quality --> <!-- AIOS-MANAGED-START: quality -->
## Quality Gates ## Quality Gates
- Rode `npm run lint` - Rode `pnpm run eslint`
- Rode `npm run typecheck` - Rode `bundle exec rubocop`
- Rode `npm test` - Rode `pnpm test`
- Rode `bundle exec rspec`
- Atualize checklist e file list da story antes de concluir - Atualize checklist e file list da story antes de concluir
<!-- AIOS-MANAGED-END: quality --> <!-- AIOS-MANAGED-END: quality -->

View File

@ -0,0 +1,113 @@
# 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

View File

@ -0,0 +1,15 @@
# 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

View File

@ -2,30 +2,43 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
def show def show
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month) period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month)
period_end = parse_date(params[:period_end], Time.zone.today) period_end = parse_date(params[:period_end], Time.zone.today)
unit = params[:unit_id].present? ? Current.account.captain_units.find_by(id: params[:unit_id]) : nil render json: build_operational_report(find_unit, find_inbox, period_start, period_end)
render json: build_operational_report(unit, period_start, period_end)
end end
private 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) def parse_date(param, default)
param.present? ? Date.parse(param) : default param.present? ? Date.parse(param) : default
rescue ArgumentError rescue ArgumentError
default default
end end
def build_operational_report(unit, period_start, period_end) def build_operational_report(unit, inbox, period_start, period_end)
conversations = scoped_conversations(unit, period_start, period_end) conversations = scoped_conversations(unit, inbox, period_start, period_end)
{ {
period: { start: period_start, end: period_end }, period: { start: period_start, end: period_end },
unit_id: unit&.id, unit_id: unit&.id,
unit_name: unit&.name, unit_name: unit&.name,
inbox_id: inbox&.id,
inbox_name: inbox&.name,
conversations: conversation_metrics(conversations), conversations: conversation_metrics(conversations),
reservations: reservation_metrics(unit, period_start, period_end), reservations: reservation_metrics(unit, inbox, period_start, period_end),
hourly_distribution: hourly_distribution(conversations), hourly_distribution: hourly_distribution(conversations),
daily_distribution: daily_distribution(conversations, period_start, period_end) daily_distribution: daily_distribution(conversations, period_start, period_end),
by_inbox: inbox.nil? ? by_inbox_breakdown(conversations) : []
} }
end end
@ -42,8 +55,8 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
} }
end end
def reservation_metrics(unit, period_start, period_end) def reservation_metrics(unit, inbox, period_start, period_end)
reservations = scoped_reservations(unit, period_start, period_end) reservations = scoped_reservations(unit, inbox, period_start, period_end)
paid = reservations.where(status: 'paid') paid = reservations.where(status: 'paid')
expired = reservations.where(status: 'expired') expired = reservations.where(status: 'expired')
@ -73,21 +86,54 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
end end
end end
def scoped_conversations(unit, period_start, period_end) def scoped_conversations(unit, inbox, period_start, period_end)
scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day) scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
if unit if inbox
scope = scope.where(inbox_id: inbox.id)
elsif unit
inbox_ids = unit.inboxes.pluck(:id) inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end end
scope scope
end end
def scoped_reservations(unit, period_start, period_end) def scoped_reservations(unit, inbox, period_start, period_end)
scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day) scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
scope = scope.where(captain_unit_id: unit.id) if unit 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
end 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) def avg_resolution_minutes(conversations)
return 0 if conversations.none? return 0 if conversations.none?

View File

@ -0,0 +1,20 @@
# 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

View File

@ -0,0 +1,14 @@
/* 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();

View File

@ -21,6 +21,18 @@ class CaptainReportsAPI extends ApiClient {
generateInsight(data) { generateInsight(data) {
return axios.post(`${this.url}/insights/generate`, 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);
}
} }
export default new CaptainReportsAPI(); export default new CaptainReportsAPI();

View File

@ -25,6 +25,18 @@ class CaptainReservations extends ApiClient {
pix(id) { pix(id) {
return axios.get(`${this.url}/${id}/pix`); 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(); export default new CaptainReservations();

View File

@ -0,0 +1,24 @@
/* 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();

View File

@ -21,6 +21,7 @@ const state = reactive({
id: '', id: '',
title: '', title: '',
description: '', description: '',
trigger_keywords: '',
instruction: '', instruction: '',
}); });
@ -55,6 +56,7 @@ const resetState = () => {
id: '', id: '',
title: '', title: '',
description: '', description: '',
trigger_keywords: '',
instruction: '', instruction: '',
}); });
}; };
@ -119,6 +121,24 @@ const onClickCancel = () => {
:message-type="descriptionError ? 'error' : 'info'" :message-type="descriptionError ? 'error' : 'info'"
show-character-count 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 <Editor
v-model="state.instruction" v-model="state.instruction"
:label=" :label="

View File

@ -26,6 +26,10 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
triggerKeywords: {
type: String,
default: '',
},
instruction: { instruction: {
type: String, type: String,
required: true, required: true,
@ -58,6 +62,7 @@ const state = reactive({
id: '', id: '',
title: '', title: '',
description: '', description: '',
trigger_keywords: '',
instruction: '', instruction: '',
}); });
@ -74,6 +79,7 @@ const startEdit = () => {
id: props.id, id: props.id,
title: props.title, title: props.title,
description: props.description, description: props.description,
trigger_keywords: props.triggerKeywords || '',
instruction: props.instruction, instruction: props.instruction,
tools: props.tools, tools: props.tools,
}); });
@ -223,6 +229,22 @@ const renderInstruction = instruction => () =>
:message-type="descriptionError ? 'error' : 'info'" :message-type="descriptionError ? 'error' : 'info'"
show-character-count 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 <Editor
v-model="state.instruction" v-model="state.instruction"
:label=" :label="

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
@ -12,21 +12,52 @@ const props = defineProps({
const emit = defineEmits(['submit']); 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 { t } = useI18n();
const promptText = ref(''); const systemText = ref('');
const originalText = ref(''); const assistantText = ref('');
const isDirty = ref(false); 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 updateStateFromAssistant = assistant => { const updateStateFromAssistant = assistant => {
// Pré-popula com o prompt customizado salvo, ou com o .liquid padrão como ponto de partida const fullText =
const initialValue =
assistant.orchestrator_prompt || assistant.orchestrator_prompt ||
assistant.default_orchestrator_prompt || assistant.default_orchestrator_prompt ||
''; '';
promptText.value = initialValue; const { system, assistant: assistantPart } = splitPrompt(fullText);
originalText.value = initialValue; systemText.value = system;
isDirty.value = false; assistantText.value = assistantPart;
originalSystem.value = system;
originalAssistant.value = assistantPart;
}; };
watch( watch(
@ -37,33 +68,30 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(promptText, newVal => {
isDirty.value = newVal !== originalText.value;
});
const handleSave = () => { const handleSave = () => {
if (!promptText.value.trim()) { const full = joinPrompt();
if (!full.trim()) {
useAlert(t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.VALIDATION_ERROR')); useAlert(t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.VALIDATION_ERROR'));
return; return;
} }
emit('submit', { orchestrator_prompt: promptText.value }); emit('submit', { orchestrator_prompt: full });
originalText.value = promptText.value; originalSystem.value = systemText.value;
isDirty.value = false; originalAssistant.value = assistantText.value;
}; };
const handleReset = () => { const handleReset = () => {
// Envia null para limpar o banco e voltar ao .liquid padrão
emit('submit', { orchestrator_prompt: null }); emit('submit', { orchestrator_prompt: null });
// Restaura a textarea para mostrar o conteúdo padrão novamente const defaultFull = props.assistant?.default_orchestrator_prompt || '';
const defaultPrompt = props.assistant?.default_orchestrator_prompt || ''; const { system, assistant: assistantPart } = splitPrompt(defaultFull);
promptText.value = defaultPrompt; systemText.value = system;
originalText.value = defaultPrompt; assistantText.value = assistantPart;
isDirty.value = false; originalSystem.value = system;
originalAssistant.value = assistantPart;
}; };
</script> </script>
<template> <template>
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-6 w-full">
<!-- Aviso de risco --> <!-- Aviso de risco -->
<div <div
class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 w-full" class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 w-full"
@ -74,23 +102,74 @@ const handleReset = () => {
</p> </p>
</div> </div>
<!-- Textarea do prompt --> <!-- 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 -->
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<div class="flex flex-col"> <div class="flex items-center gap-2">
<label class="text-sm font-medium text-n-slate-12"> <span class="i-lucide-shield text-n-slate-10 text-sm" />
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.LABEL') }} <div class="flex flex-col">
</label> <label class="text-sm font-medium text-n-slate-12">
<p class="text-xs text-n-slate-11"> {{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_LABEL') }}
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DESCRIPTION') }} </label>
</p> <p class="text-xs text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_DESCRIPTION') }}
</p>
</div>
</div> </div>
<div class="w-full"> <textarea
<textarea v-model="systemText"
v-model="promptText" :placeholder="
:placeholder="t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.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" "
/> 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> </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> </div>
<!-- Botões --> <!-- Botões -->

View File

@ -418,6 +418,18 @@ const menuItems = computed(() => {
activeOn: ['captain_reservations_index'], activeOn: ['captain_reservations_index'],
to: accountScopedRoute('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: 'Funnel',
label: t('SIDEBAR.CAPTAIN_FUNNEL'),
activeOn: ['captain_funnel_index'],
to: accountScopedRoute('captain_funnel_index'),
},
{ {
name: 'Reports', name: 'Reports',
label: t('SIDEBAR.CAPTAIN_REPORTS'), label: t('SIDEBAR.CAPTAIN_REPORTS'),

View File

@ -143,6 +143,99 @@
"PIX_COPY_FAILED": "Unable to copy Pix." "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": { "CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings", "TITLE": "Captain Settings",
"UNITS": { "UNITS": {

View File

@ -689,6 +689,11 @@
"PLACEHOLDER": "Describe how and where this scenario will be used", "PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required" "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": { "INSTRUCTION": {
"LABEL": "How to handle", "LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled", "PLACEHOLDER": "Describe how and where this scenario will be handled",

View File

@ -343,6 +343,8 @@
"CAPTAIN_PIX_UNITS": "Pix Units", "CAPTAIN_PIX_UNITS": "Pix Units",
"CAPTAIN_GALLERY": "Gallery", "CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations", "CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_ROLETA": "Roulette — Redeem",
"CAPTAIN_FUNNEL": "Conversion Funnel",
"CAPTAIN_LIFECYCLE": "Customer Journey", "CAPTAIN_LIFECYCLE": "Customer Journey",
"CAPTAIN_REPORTS": "AI Reports", "CAPTAIN_REPORTS": "AI Reports",
"CAPTAIN_NOTIFICATIONS": "Automatic Notifications", "CAPTAIN_NOTIFICATIONS": "Automatic Notifications",

View File

@ -143,6 +143,99 @@
"PIX_COPY_FAILED": "Não foi possível copiar o Pix." "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": { "CAPTAIN_SETTINGS": {
"TITLE": "Configurações do Captain", "TITLE": "Configurações do Captain",
"UNITS": { "UNITS": {

View File

@ -547,7 +547,14 @@
"RESET_BUTTON": "Restaurar Padrão", "RESET_BUTTON": "Restaurar Padrão",
"USING_DEFAULT": "Usando prompt padrão do sistema", "USING_DEFAULT": "Usando prompt padrão do sistema",
"USING_CUSTOM": "Usando prompt customizado", "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." "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"
}, },
"OPTIONS": { "OPTIONS": {
"EDIT_ASSISTANT": "Editar Assistente", "EDIT_ASSISTANT": "Editar Assistente",
@ -682,6 +689,11 @@
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado", "PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Descrição do cenário é obrigatória" "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": { "INSTRUCTION": {
"LABEL": "Como lidar", "LABEL": "Como lidar",
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado", "PLACEHOLDER": "Descreva como e onde este cenário será utilizado",

View File

@ -342,6 +342,8 @@
"CAPTAIN_PIX_UNITS": "Unidades Pix", "CAPTAIN_PIX_UNITS": "Unidades Pix",
"CAPTAIN_GALLERY": "Galeria", "CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas", "CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_ROLETA": "Roleta — Resgate",
"CAPTAIN_FUNNEL": "Funil de Conversão",
"CAPTAIN_LIFECYCLE": "Jornada do Cliente", "CAPTAIN_LIFECYCLE": "Jornada do Cliente",
"CAPTAIN_REPORTS": "Relatórios IA", "CAPTAIN_REPORTS": "Relatórios IA",
"CAPTAIN_NOTIFICATIONS": "Notificações Automáticas", "CAPTAIN_NOTIFICATIONS": "Notificações Automáticas",

View File

@ -284,6 +284,7 @@ onMounted(() => {
:key="scenario.id" :key="scenario.id"
:title="scenario.title" :title="scenario.title"
:description="scenario.description" :description="scenario.description"
:trigger-keywords="scenario.trigger_keywords"
:instruction="scenario.instruction" :instruction="scenario.instruction"
:tools="scenario.tools" :tools="scenario.tools"
:is-selected="bulkSelectedIds.has(scenario.id)" :is-selected="bulkSelectedIds.has(scenario.id)"

View File

@ -17,6 +17,8 @@ import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue'; import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue'; import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue'; import ReservationsIndex from './reservations/Index.vue';
import RoletaIndex from './roleta/Index.vue';
import FunnelIndex from './funnel/Index.vue';
import LifecycleIndex from './lifecycle/Index.vue'; import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue'; import LifecycleRules from './lifecycle/Rules.vue';
import LifecycleSettings from './lifecycle/Settings.vue'; import LifecycleSettings from './lifecycle/Settings.vue';
@ -141,6 +143,18 @@ export const routes = [
name: 'captain_reservations_index', name: 'captain_reservations_index',
meta, meta,
}, },
{
path: frontendURL('accounts/:accountId/captain/roleta'),
component: RoletaIndex,
name: 'captain_roleta_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/funnel'),
component: FunnelIndex,
name: 'captain_funnel_index',
meta,
},
{ {
path: frontendURL('accounts/:accountId/captain/lifecycle'), path: frontendURL('accounts/:accountId/captain/lifecycle'),
component: LifecycleIndex, component: LifecycleIndex,

View File

@ -0,0 +1,223 @@
<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>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
@ -36,13 +36,17 @@ const suite = ref('');
const sort = ref(''); const sort = ref('');
const isFetchingRevenue = ref(false); const isFetchingRevenue = ref(false);
const showNewReservationModal = ref(false); const showNewReservationModal = ref(false);
const showAdvancedFilters = ref(false);
// Tick reativo força pixCountdown a recalcular a cada 30s
const tickNow = ref(Date.now());
let tickInterval = null;
let refreshInterval = null;
const actionMenuOpenFor = ref(null);
const actionLoading = ref(null);
const emptyRevenue = () => ({ const emptyRevenue = () => ({
summary: { summary: { total_revenue: 0, confirmed_count: 0, average_ticket: 0 },
total_revenue: 0,
confirmed_count: 0,
average_ticket: 0,
},
by_unit: [], by_unit: [],
by_suite: [], by_suite: [],
}); });
@ -56,16 +60,32 @@ const hasRevenueData = computed(
() => Number(revenue.value.summary?.confirmed_count || 0) > 0 () => Number(revenue.value.summary?.confirmed_count || 0) > 0
); );
const statusOptions = computed(() => [ const STATUS_PILLS = [
{ id: 'all', label: 'CAPTAIN_RESERVATIONS.FILTERS.STATUS_ALL' }, { id: 'all', labelKey: 'CAPTAIN_RESERVATIONS.PILLS.ALL', tone: 'slate' },
{ id: 'draft', label: 'CAPTAIN_RESERVATIONS.STATUS.DRAFT' }, { id: 'draft', labelKey: 'CAPTAIN_RESERVATIONS.PILLS.DRAFT', tone: 'slate' },
{ {
id: 'pending_payment', id: 'pending_payment',
label: 'CAPTAIN_RESERVATIONS.STATUS.PENDING_PAYMENT', labelKey: 'CAPTAIN_RESERVATIONS.PILLS.PENDING_PAYMENT',
tone: 'amber',
}, },
{ id: 'confirmed', label: 'CAPTAIN_RESERVATIONS.STATUS.CONFIRMED' }, {
{ id: 'cancelled', label: 'CAPTAIN_RESERVATIONS.STATUS.CANCELLED' }, id: 'confirmed',
]); labelKey: 'CAPTAIN_RESERVATIONS.PILLS.CONFIRMED',
tone: 'teal',
},
{
id: 'cancelled',
labelKey: 'CAPTAIN_RESERVATIONS.PILLS.CANCELLED',
tone: 'ruby',
},
];
const QUICK_DATES = [
{ id: 'today', labelKey: 'CAPTAIN_RESERVATIONS.QUICK_DATE.TODAY' },
{ id: 'tomorrow', labelKey: 'CAPTAIN_RESERVATIONS.QUICK_DATE.TOMORROW' },
{ id: 'week', labelKey: 'CAPTAIN_RESERVATIONS.QUICK_DATE.WEEK' },
{ id: 'all', labelKey: 'CAPTAIN_RESERVATIONS.QUICK_DATE.ALL' },
];
const groupedReservations = computed(() => { const groupedReservations = computed(() => {
const groups = { const groups = {
@ -74,16 +94,53 @@ const groupedReservations = computed(() => {
confirmed: [], confirmed: [],
cancelled: [], cancelled: [],
}; };
reservations.value.forEach(reservation => { reservations.value.forEach(reservation => {
const key = reservation.ui_status || 'draft'; const key = reservation.ui_status || 'draft';
if (!groups[key]) groups[key] = []; if (!groups[key]) groups[key] = [];
groups[key].push(reservation); groups[key].push(reservation);
}); });
return groups; return groups;
}); });
const statusCounts = computed(() => {
const counts = {
all: reservations.value.length,
draft: 0,
pending_payment: 0,
confirmed: 0,
cancelled: 0,
};
reservations.value.forEach(r => {
const key = r.ui_status || 'draft';
if (counts[key] !== undefined) counts[key] += 1;
});
return counts;
});
const todayRevenue = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return reservations.value
.filter(r => r.ui_status === 'confirmed')
.filter(r => {
if (!r.check_in_at) return false;
const d = new Date(r.check_in_at);
return d >= today && d < new Date(today.getTime() + 86400000);
})
.reduce((sum, r) => sum + Number(r.amount || 0), 0);
});
const pendingPixCount = computed(() => statusCounts.value.pending_payment);
const confirmedTodayCount = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return reservations.value.filter(r => {
if (r.ui_status !== 'confirmed' || !r.check_in_at) return false;
const d = new Date(r.check_in_at);
return d >= today && d < new Date(today.getTime() + 86400000);
}).length;
});
const readFiltersFromRoute = () => { const readFiltersFromRoute = () => {
const query = route.query || {}; const query = route.query || {};
status.value = query.status || 'all'; status.value = query.status || 'all';
@ -166,6 +223,59 @@ const setViewMode = mode => {
fetchReservations(1); fetchReservations(1);
}; };
const setStatusPill = id => {
if (status.value === id) return;
status.value = id;
fetchReservations(1);
};
const setUnitPill = id => {
const next = String(id || '');
if (unitId.value === next) return;
unitId.value = next;
fetchReservations(1);
};
const setQuickDate = preset => {
const now = new Date();
const iso = d => d.toISOString().slice(0, 10);
if (preset === 'today') {
dateFrom.value = iso(now);
dateTo.value = iso(now);
} else if (preset === 'tomorrow') {
const tomorrow = new Date(now.getTime() + 86400000);
dateFrom.value = iso(tomorrow);
dateTo.value = iso(tomorrow);
} else if (preset === 'week') {
const in7 = new Date(now.getTime() + 7 * 86400000);
dateFrom.value = iso(now);
dateTo.value = iso(in7);
} else {
dateFrom.value = '';
dateTo.value = '';
}
fetchReservations(1);
};
const isQuickDateActive = preset => {
if (preset === 'all') return !dateFrom.value && !dateTo.value;
const iso = d => d.toISOString().slice(0, 10);
const now = new Date();
if (preset === 'today') {
const today = iso(now);
return dateFrom.value === today && dateTo.value === today;
}
if (preset === 'tomorrow') {
const tomorrow = iso(new Date(now.getTime() + 86400000));
return dateFrom.value === tomorrow && dateTo.value === tomorrow;
}
if (preset === 'week') {
const in7 = iso(new Date(now.getTime() + 7 * 86400000));
return dateFrom.value === iso(now) && dateTo.value === in7;
}
return false;
};
const onPageChange = page => fetchReservations(page); const onPageChange = page => fetchReservations(page);
const applyFilters = () => { const applyFilters = () => {
@ -196,10 +306,7 @@ const openConversation = reservation => {
reservation.conversation_display_id || reservation.conversation_id; reservation.conversation_display_id || reservation.conversation_id;
if (!conversationId) return; if (!conversationId) return;
const path = frontendURL( const path = frontendURL(
conversationUrl({ conversationUrl({ accountId: route.params.accountId, id: conversationId })
accountId: route.params.accountId,
id: conversationId,
})
); );
router.push(path); router.push(path);
}; };
@ -214,7 +321,6 @@ const copyPix = async reservation => {
); );
return; return;
} }
try { try {
await navigator.clipboard.writeText(pix); await navigator.clipboard.writeText(pix);
useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPIED')); useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPIED'));
@ -228,14 +334,52 @@ const formatMoney = value =>
Number(value || 0) Number(value || 0)
); );
const formatDate = value => const formatCheckIn = value => {
value if (!value) return '-';
? new Intl.DateTimeFormat('pt-BR', { const d = new Date(value);
day: '2-digit', const today = new Date();
month: '2-digit', today.setHours(0, 0, 0, 0);
year: 'numeric', const target = new Date(d);
}).format(new Date(value)) target.setHours(0, 0, 0, 0);
: '-'; const diffDays = Math.round((target - today) / 86400000);
const hour = d.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
if (diffDays === 0) return `${t('CAPTAIN_RESERVATIONS.CARD.TODAY')} ${hour}`;
if (diffDays === 1)
return `${t('CAPTAIN_RESERVATIONS.CARD.TOMORROW')} ${hour}`;
if (diffDays === -1)
return `${t('CAPTAIN_RESERVATIONS.CARD.YESTERDAY')} ${hour}`;
const weekday = d.toLocaleDateString('pt-BR', { weekday: 'short' });
const day = d.toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
});
return `${weekday} ${day} ${hour}`;
};
const pixCountdown = reservation => {
const expires = reservation.pix_expires_at;
if (!expires) return null;
// depender de tickNow força recomputação
const diff = new Date(expires).getTime() - tickNow.value;
if (diff <= 0)
return { label: t('CAPTAIN_RESERVATIONS.CARD.PIX_EXPIRED'), expired: true };
const mins = Math.floor(diff / 60000);
if (mins < 60)
return {
label: t('CAPTAIN_RESERVATIONS.CARD.PIX_EXPIRES_IN_MIN', {
minutes: mins,
}),
expired: false,
};
const hours = Math.floor(mins / 60);
return {
label: t('CAPTAIN_RESERVATIONS.CARD.PIX_EXPIRES_IN_HR', { hours }),
expired: false,
};
};
const unitRevenueChart = computed(() => ({ const unitRevenueChart = computed(() => ({
labels: revenue.value.by_unit.map(item => item.unit_name || '-'), labels: revenue.value.by_unit.map(item => item.unit_name || '-'),
@ -259,23 +403,144 @@ const suiteRevenueChart = computed(() => ({
], ],
})); }));
const statusColor = reservationStatus => { const statusBadgeClass = reservationStatus => {
const colors = { const map = {
draft: 'bg-n-slate-3 text-n-slate-11', draft: 'bg-n-slate-3 text-n-slate-11',
pending_payment: 'bg-n-amber-3 text-n-amber-11', pending_payment: 'bg-n-amber-3 text-n-amber-11',
confirmed: 'bg-n-teal-3 text-n-teal-11', confirmed: 'bg-n-teal-3 text-n-teal-11',
cancelled: 'bg-n-ruby-3 text-n-ruby-11', cancelled: 'bg-n-ruby-3 text-n-ruby-11',
}; };
return colors[reservationStatus] || 'bg-n-slate-3 text-n-slate-11'; return map[reservationStatus] || 'bg-n-slate-3 text-n-slate-11';
};
const statusBarClass = reservationStatus => {
const map = {
draft: 'bg-n-slate-7',
pending_payment: 'bg-n-amber-9',
confirmed: 'bg-n-teal-9',
cancelled: 'bg-n-ruby-9',
};
return map[reservationStatus] || 'bg-n-slate-7';
};
const pillClass = (pill, active) => {
const toneActive = {
slate: 'bg-n-slate-12 text-white border-n-slate-12',
amber: 'bg-n-amber-9 text-white border-n-amber-9',
teal: 'bg-n-teal-9 text-white border-n-teal-9',
ruby: 'bg-n-ruby-9 text-white border-n-ruby-9',
};
return active
? toneActive[pill.tone]
: 'bg-n-background text-n-slate-11 border-n-weak hover:bg-n-surface-2';
};
// Ações: reenviar PIX / marcar como pago / cancelar
const regeneratePix = async reservation => {
if (actionLoading.value) return;
actionLoading.value = reservation.id;
try {
await store.dispatch('captainReservations/regeneratePix', reservation.id);
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.PIX_REGENERATED'));
fetchReservations(reservationsMeta.value.page || 1);
} catch (error) {
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.PIX_REGENERATE_FAILED'));
} finally {
actionLoading.value = null;
actionMenuOpenFor.value = null;
}
};
const markAsPaid = async reservation => {
if (actionLoading.value) return;
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN_RESERVATIONS.ACTIONS.MARK_AS_PAID_CONFIRM'))) {
return;
}
actionLoading.value = reservation.id;
try {
await store.dispatch('captainReservations/markAsPaid', {
id: reservation.id,
});
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.MARKED_AS_PAID'));
fetchReservations(reservationsMeta.value.page || 1);
} catch (error) {
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.MARK_AS_PAID_FAILED'));
} finally {
actionLoading.value = null;
actionMenuOpenFor.value = null;
}
};
const cancelReservation = async reservation => {
if (actionLoading.value) return;
// eslint-disable-next-line no-alert
const reason = window.prompt(
t('CAPTAIN_RESERVATIONS.ACTIONS.CANCEL_REASON_PROMPT'),
''
);
if (reason === null) return; // cancelou o prompt
actionLoading.value = reservation.id;
try {
await store.dispatch('captainReservations/cancel', {
id: reservation.id,
reason,
});
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.CANCELLED'));
fetchReservations(reservationsMeta.value.page || 1);
} catch (error) {
useAlert(t('CAPTAIN_RESERVATIONS.ACTIONS.CANCEL_FAILED'));
} finally {
actionLoading.value = null;
actionMenuOpenFor.value = null;
}
};
const toggleActionMenu = reservationId => {
actionMenuOpenFor.value =
actionMenuOpenFor.value === reservationId ? null : reservationId;
};
const hasPendingReservations = computed(() =>
reservations.value.some(r => r.ui_status === 'pending_payment')
);
// Auto-refresh: 30s quando tem pendente e aba visível
const startAutoRefresh = () => {
if (refreshInterval) return;
refreshInterval = setInterval(() => {
if (document.hidden) return;
if (!hasPendingReservations.value) return;
if (isPageFetching.value) return;
if (viewMode.value !== 'list') return;
fetchReservations(reservationsMeta.value.page || 1);
}, 30000);
};
const startTick = () => {
if (tickInterval) return;
tickInterval = setInterval(() => {
tickNow.value = Date.now();
}, 30000);
}; };
onMounted(() => { onMounted(() => {
readFiltersFromRoute(); readFiltersFromRoute();
store.dispatch('captainUnits/get');
if (isRevenueView.value) { if (isRevenueView.value) {
fetchRevenue(); fetchRevenue();
return; } else {
fetchReservations(Number(route.query.page) || 1);
} }
fetchReservations(Number(route.query.page) || 1); startTick();
startAutoRefresh();
});
onBeforeUnmount(() => {
if (tickInterval) clearInterval(tickInterval);
if (refreshInterval) clearInterval(refreshInterval);
tickInterval = null;
refreshInterval = null;
}); });
</script> </script>
@ -301,39 +566,150 @@ onMounted(() => {
@update:current-page="onPageChange" @update:current-page="onPageChange"
> >
<template #controls> <template #controls>
<div <!-- KPI strip -->
class="grid grid-cols-1 gap-3 p-4 mb-4 rounded-xl bg-n-surface-2 md:grid-cols-7" <div class="grid grid-cols-2 gap-3 mb-4 md:grid-cols-4">
> <div class="p-3 border rounded-xl bg-n-background border-n-weak">
<div class="md:col-span-2"> <p class="text-xs text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.KPI.TOTAL') }}
</p>
<p class="mt-1 text-xl font-semibold text-n-slate-12">
{{ reservationsMeta.totalCount || reservations.length || 0 }}
</p>
</div>
<div
class="p-3 border rounded-xl bg-n-amber-2 border-n-amber-6"
:class="{ 'ring-2 ring-n-amber-9': pendingPixCount > 0 }"
>
<p class="text-xs text-n-amber-11">
{{ $t('CAPTAIN_RESERVATIONS.KPI.PENDING_PIX') }}
</p>
<p class="mt-1 text-xl font-semibold text-n-amber-12">
{{ pendingPixCount }}
</p>
</div>
<div class="p-3 border rounded-xl bg-n-teal-2 border-n-teal-6">
<p class="text-xs text-n-teal-11">
{{ $t('CAPTAIN_RESERVATIONS.KPI.CHECKIN_TODAY') }}
</p>
<p class="mt-1 text-xl font-semibold text-n-teal-12">
{{ confirmedTodayCount }}
</p>
</div>
<div class="p-3 border rounded-xl bg-n-background border-n-weak">
<p class="text-xs text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.KPI.REVENUE_TODAY') }}
</p>
<p class="mt-1 text-xl font-semibold text-n-slate-12">
{{ formatMoney(todayRevenue) }}
</p>
</div>
</div>
<!-- Unit pills (uma caixa de entrada = uma unidade) -->
<div v-if="units.length > 1" class="flex flex-wrap gap-2 mb-3">
<button
type="button"
class="px-3 py-1.5 text-xs font-medium border rounded-full transition-colors"
:class="
!unitId
? 'bg-n-slate-12 text-white border-n-slate-12'
: 'bg-n-background text-n-slate-11 border-n-weak hover:bg-n-surface-2'
"
@click="setUnitPill('')"
>
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.UNIT_ALL') }}
</button>
<button
v-for="unit in units"
:key="unit.id"
type="button"
class="px-3 py-1.5 text-xs font-medium border rounded-full transition-colors"
:class="
String(unitId) === String(unit.id)
? 'bg-n-slate-12 text-white border-n-slate-12'
: 'bg-n-background text-n-slate-11 border-n-weak hover:bg-n-surface-2'
"
@click="setUnitPill(unit.id)"
>
{{ unit.name }}
</button>
</div>
<!-- Status pills -->
<div class="flex flex-wrap gap-2 mb-3">
<button
v-for="pill in STATUS_PILLS"
:key="pill.id"
type="button"
class="px-3 py-1.5 text-xs font-medium border rounded-full transition-colors"
:class="pillClass(pill, status === pill.id)"
@click="setStatusPill(pill.id)"
>
{{ $t(pill.labelKey) }}
<span
v-if="pill.id !== 'all' && statusCounts[pill.id]"
class="ml-1 opacity-80"
>
· {{ statusCounts[pill.id] }}
</span>
</button>
</div>
<!-- Quick date + search + toggle -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<div class="flex gap-1">
<button
v-for="preset in QUICK_DATES"
:key="preset.id"
type="button"
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-colors"
:class="
isQuickDateActive(preset.id)
? 'bg-n-slate-12 text-white border-n-slate-12'
: 'bg-n-background text-n-slate-11 border-n-weak hover:bg-n-surface-2'
"
@click="setQuickDate(preset.id)"
>
{{ $t(preset.labelKey) }}
</button>
</div>
<div class="flex-1 min-w-[200px]">
<Input <Input
v-model="q" v-model="q"
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.SEARCH')" :placeholder="$t('CAPTAIN_RESERVATIONS.FILTERS.SEARCH')"
@keyup.enter="applyFilters"
/> />
</div> </div>
<div v-if="!isRevenueView"> <Button
<label class="text-sm text-n-slate-11">{{ :label="
$t('CAPTAIN_RESERVATIONS.FILTERS.STATUS') showAdvancedFilters
}}</label> ? $t('CAPTAIN_RESERVATIONS.FILTERS.HIDE')
<select : $t('CAPTAIN_RESERVATIONS.FILTERS.APPLY')
v-model="status" "
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak" size="sm"
> variant="outline"
<option @click="showAdvancedFilters = !showAdvancedFilters"
v-for="option in statusOptions" />
:key="option.id" <Button
:value="option.id" :label="$t('CAPTAIN_RESERVATIONS.FILTERS.CLEAR')"
> size="sm"
{{ $t(option.label) }} variant="ghost"
</option> @click="clearFilters"
</select> />
</div> </div>
<!-- Advanced filters (collapsible) -->
<div
v-if="showAdvancedFilters"
class="grid grid-cols-1 gap-3 p-4 mb-4 rounded-xl bg-n-surface-2 md:grid-cols-5"
>
<div> <div>
<label class="text-sm text-n-slate-11">{{ <label class="text-xs text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.FILTERS.UNIT') $t('CAPTAIN_RESERVATIONS.FILTERS.UNIT')
}}</label> }}</label>
<select <select
v-model="unitId" v-model="unitId"
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak" class="w-full px-2 py-2 mt-1 text-sm border rounded-lg bg-n-background border-n-weak"
> >
<option value=""> <option value="">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.UNIT_ALL') }} {{ $t('CAPTAIN_RESERVATIONS.FILTERS.UNIT_ALL') }}
@ -364,13 +740,13 @@ onMounted(() => {
/> />
</div> </div>
<div> <div>
<label class="text-sm text-n-slate-11">{{ <label class="text-xs text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.FILTERS.SORT') $t('CAPTAIN_RESERVATIONS.FILTERS.SORT')
}}</label> }}</label>
<select <select
v-model="sort" v-model="sort"
:disabled="isRevenueView" :disabled="isRevenueView"
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak" class="w-full px-2 py-2 mt-1 text-sm border rounded-lg bg-n-background border-n-weak"
> >
<option value=""> <option value="">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_DEFAULT') }} {{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_DEFAULT') }}
@ -386,35 +762,7 @@ onMounted(() => {
</option> </option>
</select> </select>
</div> </div>
</div> <div class="md:col-span-5">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.LIST')"
:variant="viewMode === 'list' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('list')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.KANBAN')"
:variant="viewMode === 'kanban' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('kanban')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.REVENUE')"
:variant="viewMode === 'revenue' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('revenue')"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.CLEAR')"
variant="ghost"
size="sm"
@click="clearFilters"
/>
<Button <Button
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.APPLY')" :label="$t('CAPTAIN_RESERVATIONS.FILTERS.APPLY')"
size="sm" size="sm"
@ -422,6 +770,28 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
<!-- View mode toggle -->
<div class="flex items-center justify-end gap-2 mb-4">
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.LIST')"
:variant="viewMode === 'list' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('list')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.KANBAN')"
:variant="viewMode === 'kanban' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('kanban')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.REVENUE')"
:variant="viewMode === 'revenue' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('revenue')"
/>
</div>
</template> </template>
<template #emptyState> <template #emptyState>
@ -435,99 +805,155 @@ onMounted(() => {
<Spinner /> <Spinner />
</div> </div>
<!-- CARDS GRID (replaces table) -->
<div <div
v-else-if="viewMode === 'list'" v-else-if="viewMode === 'list'"
class="overflow-x-auto border rounded-xl border-n-weak" class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
> >
<table class="w-full text-sm"> <article
<thead class="bg-n-surface-2 text-n-slate-11"> v-for="reservation in reservations"
<tr> :key="reservation.id"
<th class="px-3 py-2 text-left"> class="relative flex flex-col p-4 pl-5 overflow-hidden transition-all border rounded-xl bg-n-background border-n-weak hover:border-n-slate-7 hover:shadow-md"
{{ $t('CAPTAIN_RESERVATIONS.TABLE.CUSTOMER') }} >
</th> <!-- Colored status bar (left) -->
<th class="px-3 py-2 text-left"> <span
{{ $t('CAPTAIN_RESERVATIONS.TABLE.UNIT') }} class="absolute top-0 bottom-0 left-0 w-1"
</th> :class="statusBarClass(reservation.ui_status)"
<th class="px-3 py-2 text-left"> />
{{ $t('CAPTAIN_RESERVATIONS.TABLE.SUITE') }}
</th> <!-- Header row -->
<th class="px-3 py-2 text-left"> <div class="flex items-start justify-between gap-2">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.CHECK_IN') }} <div class="min-w-0">
</th> <h3
<th class="px-3 py-2 text-left"> class="text-base font-semibold truncate text-n-slate-12"
{{ $t('CAPTAIN_RESERVATIONS.TABLE.AMOUNT') }} :title="reservation.customer_name"
</th> >
<th class="px-3 py-2 text-left"> {{ reservation.customer_name || '—' }}
{{ $t('CAPTAIN_RESERVATIONS.TABLE.STATUS') }} </h3>
</th> <p class="mt-0.5 text-xs text-n-slate-11 truncate">
<th class="px-3 py-2 text-left"> {{
{{ $t('CAPTAIN_RESERVATIONS.TABLE.UPDATED_AT') }} reservation.customer_phone || reservation.customer_cpf || '—'
</th> }}
<th class="px-3 py-2 text-left"> </p>
{{ $t('CAPTAIN_RESERVATIONS.TABLE.ACTIONS') }} </div>
</th> <span
</tr> class="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide rounded-full font-semibold"
</thead> :class="statusBadgeClass(reservation.ui_status)"
<tbody>
<tr
v-for="reservation in reservations"
:key="reservation.id"
class="border-t border-n-weak"
> >
<td class="px-3 py-2"> {{ reservation.status_label }}
<p class="font-medium text-n-slate-12"> </span>
{{ reservation.customer_name || '-' }} </div>
</p>
<p class="text-xs text-n-slate-11"> <!-- Suite + unit -->
{{ <div class="mt-3 space-y-1">
reservation.customer_phone || <p class="text-sm font-medium text-n-slate-12">
reservation.customer_cpf || {{ reservation.suite_identifier || '—' }}
'-' </p>
}} <p class="text-xs text-n-slate-11">
</p> {{ reservation.unit_name || '—' }}
</td> </p>
<td class="px-3 py-2">{{ reservation.unit_name || '-' }}</td> </div>
<td class="px-3 py-2">
{{ reservation.suite_identifier || '-' }} <!-- Check-in + amount -->
</td> <div
<td class="px-3 py-2"> class="flex items-end justify-between gap-2 mt-3 pt-3 border-t border-n-weak"
{{ formatDate(reservation.check_in_at) }} >
</td> <div>
<td class="px-3 py-2">{{ formatMoney(reservation.amount) }}</td> <p class="text-[10px] uppercase text-n-slate-10">
<td class="px-3 py-2"> {{ $t('CAPTAIN_RESERVATIONS.CARD.CHECK_IN') }}
<span </p>
class="px-2 py-1 text-xs rounded-full font-medium" <p class="text-sm font-medium text-n-slate-12">
:class="statusColor(reservation.ui_status)" {{ formatCheckIn(reservation.check_in_at) }}
> </p>
{{ reservation.status_label }} </div>
</span> <div class="text-right">
</td> <p class="text-[10px] uppercase text-n-slate-10">
<td class="px-3 py-2"> {{ $t('CAPTAIN_RESERVATIONS.CARD.AMOUNT') }}
{{ formatDate(reservation.updated_at) }} </p>
</td> <p class="text-base font-semibold text-n-slate-12">
<td class="px-3 py-2"> {{ formatMoney(reservation.amount) }}
<div class="flex items-center gap-2"> </p>
<Button </div>
size="xs" </div>
variant="outline"
:label=" <!-- PIX countdown (only pending) -->
$t('CAPTAIN_RESERVATIONS.ACTIONS.OPEN_CONVERSATION') <div
" v-if="
@click="openConversation(reservation)" reservation.ui_status === 'pending_payment' &&
/> pixCountdown(reservation)
<Button "
size="xs" class="mt-2 px-2 py-1 text-[11px] rounded-md text-center"
variant="ghost" :class="
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.COPY_PIX')" pixCountdown(reservation).expired
@click="copyPix(reservation)" ? 'bg-n-ruby-3 text-n-ruby-11'
/> : 'bg-n-amber-3 text-n-amber-11'
</div> "
</td> >
</tr> {{ '⏱ ' + pixCountdown(reservation).label }}
</tbody> </div>
</table>
<!-- Actions -->
<div class="relative flex gap-2 mt-3">
<Button
size="xs"
variant="outline"
class="flex-1"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.OPEN_CONVERSATION')"
@click="openConversation(reservation)"
/>
<Button
size="xs"
variant="ghost"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.COPY_PIX')"
@click="copyPix(reservation)"
/>
<Button
size="xs"
variant="ghost"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.MORE')"
:disabled="actionLoading === reservation.id"
@click="toggleActionMenu(reservation.id)"
/>
<div
v-if="actionMenuOpenFor === reservation.id"
class="absolute right-0 z-10 w-56 mt-1 overflow-hidden border rounded-lg shadow-lg top-full bg-n-background border-n-weak"
>
<button
v-if="reservation.ui_status === 'pending_payment'"
type="button"
class="block w-full px-3 py-2 text-xs text-left text-n-slate-12 hover:bg-n-surface-2"
:disabled="actionLoading === reservation.id"
@click="regeneratePix(reservation)"
>
{{ $t('CAPTAIN_RESERVATIONS.ACTIONS.REGENERATE_PIX') }}
</button>
<button
v-if="
reservation.ui_status === 'pending_payment' ||
reservation.ui_status === 'draft'
"
type="button"
class="block w-full px-3 py-2 text-xs text-left text-n-slate-12 hover:bg-n-surface-2"
:disabled="actionLoading === reservation.id"
@click="markAsPaid(reservation)"
>
{{ $t('CAPTAIN_RESERVATIONS.ACTIONS.MARK_AS_PAID') }}
</button>
<button
v-if="reservation.ui_status !== 'cancelled'"
type="button"
class="block w-full px-3 py-2 text-xs text-left text-n-ruby-11 hover:bg-n-surface-2"
:disabled="actionLoading === reservation.id"
@click="cancelReservation(reservation)"
>
{{ $t('CAPTAIN_RESERVATIONS.ACTIONS.CANCEL') }}
</button>
</div>
</div>
</article>
</div> </div>
<!-- REVENUE view (unchanged logic) -->
<div v-else-if="viewMode === 'revenue'" class="space-y-4"> <div v-else-if="viewMode === 'revenue'" class="space-y-4">
<div <div
class="px-3 py-2 text-xs rounded-lg bg-n-surface-2 text-n-slate-11" class="px-3 py-2 text-xs rounded-lg bg-n-surface-2 text-n-slate-11"
@ -581,6 +1007,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- KANBAN view (same cards) -->
<div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-4"> <div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div <div
v-for="column in [ v-for="column in [
@ -592,24 +1019,36 @@ onMounted(() => {
:key="column" :key="column"
class="p-3 border rounded-xl bg-n-surface-2 border-n-weak" class="p-3 border rounded-xl bg-n-surface-2 border-n-weak"
> >
<h3 class="mb-3 text-sm font-medium text-n-slate-12"> <div class="flex items-center justify-between mb-3">
{{ $t(`CAPTAIN_RESERVATIONS.STATUS.${column.toUpperCase()}`) }} <h3 class="text-sm font-medium text-n-slate-12">
</h3> {{ $t(`CAPTAIN_RESERVATIONS.STATUS.${column.toUpperCase()}`) }}
</h3>
<span class="text-xs text-n-slate-11">
{{ groupedReservations[column].length }}
</span>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <article
v-for="reservation in groupedReservations[column]" v-for="reservation in groupedReservations[column]"
:key="reservation.id" :key="reservation.id"
class="p-3 border rounded-lg bg-n-background border-n-weak" class="relative p-3 pl-4 overflow-hidden border rounded-lg bg-n-background border-n-weak"
> >
<p class="text-sm font-medium text-n-slate-12"> <span
{{ reservation.customer_name || '-' }} class="absolute top-0 bottom-0 left-0 w-1"
:class="statusBarClass(reservation.ui_status)"
/>
<p class="text-sm font-semibold text-n-slate-12 truncate">
{{ reservation.customer_name || '—' }}
</p> </p>
<p class="text-xs text-n-slate-11"> <p class="text-xs text-n-slate-11 truncate">
{{ reservation.suite_identifier || '-' }} {{ reservation.suite_identifier || '—' }} ·
{{ reservation.unit_name || '—' }}
</p> </p>
<p class="mt-2 text-xs text-n-slate-11"> <p class="mt-2 text-xs text-n-slate-11">
{{ formatDate(reservation.check_in_at) }} {{ formatCheckIn(reservation.check_in_at) }} ·
{{ formatMoney(reservation.amount) }} <span class="font-medium text-n-slate-12">{{
formatMoney(reservation.amount)
}}</span>
</p> </p>
<div class="flex gap-2 mt-3"> <div class="flex gap-2 mt-3">
<Button <Button
@ -625,7 +1064,7 @@ onMounted(() => {
@click="copyPix(reservation)" @click="copyPix(reservation)"
/> />
</div> </div>
</div> </article>
<p <p
v-if="!groupedReservations[column].length" v-if="!groupedReservations[column].length"
class="text-xs text-n-slate-11" class="text-xs text-n-slate-11"

View File

@ -0,0 +1,532 @@
<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 Input from 'dashboard/components-next/input/Input.vue';
import roletaApi from 'dashboard/api/captain/roleta';
const { t } = useI18n();
const tab = ref('resgate');
const code = ref('');
const notes = ref('');
const submitting = ref(false);
const loadingPending = ref(false);
const pending = ref([]);
const lastResult = ref(null);
const reportPeriod = ref(7);
const report = ref(null);
const loadingReport = ref(false);
const canSubmit = computed(
() => code.value.trim().length >= 4 && !submitting.value
);
const ERROR_KEYS = {
empty_code: 'CAPTAIN_ROLETA.REDEEM.ERROR_EMPTY_CODE',
not_found: 'CAPTAIN_ROLETA.REDEEM.ERROR_NOT_FOUND',
already_redeemed: 'CAPTAIN_ROLETA.REDEEM.ERROR_ALREADY_REDEEMED',
no_prize_to_claim: 'CAPTAIN_ROLETA.REDEEM.ERROR_NO_PRIZE',
no_receptionist: 'CAPTAIN_ROLETA.REDEEM.ERROR_NO_RECEPTIONIST',
rpc_failed: 'CAPTAIN_ROLETA.REDEEM.ERROR_RPC_FAILED',
exception: 'CAPTAIN_ROLETA.REDEEM.ERROR_EXCEPTION',
};
function errorText(errCode) {
const key = ERROR_KEYS[errCode];
return key ? t(key) : t('CAPTAIN_ROLETA.REDEEM.ERROR_DEFAULT');
}
function fmtDateTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function fmtPrize(row) {
if (row.prize_tipo === 'desconto_percentual') {
return `${Number(row.prize_valor)}% OFF`;
}
return row.prize_nome;
}
function fmtCurrency(v) {
return (Number(v) || 0).toLocaleString('pt-BR', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
async function loadPending() {
loadingPending.value = true;
try {
const res = await roletaApi.pending({ days_back: 7 });
pending.value = res.data?.pending ?? [];
} catch (err) {
useAlert(t('CAPTAIN_ROLETA.HISTORY.LOAD_ERROR'));
} finally {
loadingPending.value = false;
}
}
async function submitRedeem() {
if (!canSubmit.value) return;
submitting.value = true;
lastResult.value = null;
try {
const { data } = await roletaApi.redeem(code.value.trim(), notes.value);
lastResult.value = { success: true, ...data.result };
useAlert(
t('CAPTAIN_ROLETA.REDEEM.SUCCESS_PREFIX').replace(
'{prize}',
fmtPrize(data.result)
)
);
code.value = '';
notes.value = '';
await loadPending();
} catch (err) {
const resp = err?.response?.data;
const errCode = resp?.error_code;
lastResult.value = {
success: false,
error_code: errCode,
...(resp?.result ?? {}),
};
useAlert(errorText(errCode));
} finally {
submitting.value = false;
}
}
async function loadReport() {
loadingReport.value = true;
try {
const { data } = await roletaApi.weeklyReport(reportPeriod.value);
report.value = data;
} catch (err) {
useAlert(t('CAPTAIN_ROLETA.REPORT.LOAD_ERROR'));
} finally {
loadingReport.value = false;
}
}
function switchTab(newTab) {
tab.value = newTab;
if (newTab === 'relatorio' && !report.value) loadReport();
}
onMounted(() => {
loadPending();
});
</script>
<template>
<PageLayout
:header-title="t('CAPTAIN_ROLETA.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 gap-1 border-b border-n-weak">
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition"
:class="[
tab === 'resgate'
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12',
]"
@click="switchTab('resgate')"
>
{{ t('CAPTAIN_ROLETA.TAB_REDEEM') }}
</button>
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition"
:class="[
tab === 'relatorio'
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12',
]"
@click="switchTab('relatorio')"
>
{{ t('CAPTAIN_ROLETA.TAB_REPORT') }}
</button>
</div>
<template v-if="tab === 'resgate'">
<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-1">
{{ t('CAPTAIN_ROLETA.REDEEM.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mb-4">
{{ t('CAPTAIN_ROLETA.REDEEM.DESC') }}
</p>
<form class="flex flex-col gap-3" @submit.prevent="submitRedeem">
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1 uppercase tracking-wide"
>
{{ t('CAPTAIN_ROLETA.REDEEM.CODE_LABEL') }}
</label>
<Input
v-model="code"
:placeholder="t('CAPTAIN_ROLETA.REDEEM.CODE_PLACEHOLDER')"
class="uppercase font-mono tracking-widest text-lg"
maxlength="12"
autofocus
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1 uppercase tracking-wide"
>
{{ t('CAPTAIN_ROLETA.REDEEM.NOTES_LABEL') }}
</label>
<Input
v-model="notes"
:placeholder="t('CAPTAIN_ROLETA.REDEEM.NOTES_PLACEHOLDER')"
/>
</div>
<div class="flex justify-end mt-2">
<Button
type="submit"
:label="
submitting
? t('CAPTAIN_ROLETA.REDEEM.SUBMITTING')
: t('CAPTAIN_ROLETA.REDEEM.SUBMIT')
"
:disabled="!canSubmit"
:is-loading="submitting"
/>
</div>
</form>
<div
v-if="lastResult"
class="mt-4 rounded-lg border p-3 text-sm"
:class="[
lastResult.success
? 'border-n-teal-7 bg-n-teal-3 text-n-teal-12'
: 'border-n-ruby-7 bg-n-ruby-3 text-n-ruby-12',
]"
>
<div v-if="lastResult.success" class="font-medium">
{{
t('CAPTAIN_ROLETA.REDEEM.SUCCESS_FULL', {
prize: fmtPrize(lastResult),
name:
lastResult.contact_name ||
t('CAPTAIN_ROLETA.REDEEM.FALLBACK_CLIENT'),
})
}}
</div>
<div v-else class="font-medium">
{{
t('CAPTAIN_ROLETA.REDEEM.ERROR_FULL', {
message: errorText(lastResult.error_code),
})
}}
</div>
</div>
</div>
<div
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-n-slate-12">
{{ t('CAPTAIN_ROLETA.HISTORY.TITLE') }}
</h2>
<Button
variant="ghost"
icon="i-lucide-refresh-cw"
size="xs"
:is-loading="loadingPending"
@click="loadPending"
/>
</div>
<div v-if="loadingPending" class="text-sm text-n-slate-11 py-3">
{{ t('CAPTAIN_ROLETA.HISTORY.LOADING') }}
</div>
<div
v-else-if="pending.length === 0"
class="text-sm text-n-slate-11 py-3"
>
{{ t('CAPTAIN_ROLETA.HISTORY.EMPTY') }}
</div>
<div v-else 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_ROLETA.HISTORY.COL_CODE') }}
</th>
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_ROLETA.HISTORY.COL_PRIZE') }}
</th>
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_ROLETA.HISTORY.COL_CLIENT') }}
</th>
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_ROLETA.HISTORY.COL_GENERATED') }}
</th>
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_ROLETA.HISTORY.COL_STATUS') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pending"
:key="row.code"
class="border-b border-n-weak last:border-b-0"
>
<td class="py-2 px-2 font-mono font-bold text-n-slate-12">
{{ row.code }}
</td>
<td class="py-2 px-2">{{ fmtPrize(row) }}</td>
<td class="py-2 px-2 text-n-slate-11">
{{ row.contact_name || row.contact_phone || '—' }}
</td>
<td class="py-2 px-2 text-n-slate-11">
{{ fmtDateTime(row.revealed_at) }}
</td>
<td class="py-2 px-2">
<span
v-if="row.redeemed_at"
class="inline-flex items-center gap-1 text-n-teal-11 text-xs"
>
{{ t('CAPTAIN_ROLETA.HISTORY.STATUS_REDEEMED_PREFIX') }}
{{ fmtDateTime(row.redeemed_at) }}
</span>
<span
v-else
class="inline-flex items-center gap-1 text-n-amber-11 text-xs"
>
{{ t('CAPTAIN_ROLETA.HISTORY.STATUS_PENDING') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template v-if="tab === 'relatorio'">
<div
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-n-slate-12">
{{ t('CAPTAIN_ROLETA.REPORT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN_ROLETA.REPORT.DESC') }}
</p>
</div>
<div class="flex items-center gap-2">
<select
v-model.number="reportPeriod"
class="rounded-md border border-n-weak bg-transparent text-sm px-2 py-1"
@change="loadReport"
>
<option :value="7">
{{ t('CAPTAIN_ROLETA.REPORT.PERIOD_7') }}
</option>
<option :value="14">
{{ t('CAPTAIN_ROLETA.REPORT.PERIOD_14') }}
</option>
<option :value="30">
{{ t('CAPTAIN_ROLETA.REPORT.PERIOD_30') }}
</option>
</select>
<Button
variant="ghost"
icon="i-lucide-refresh-cw"
size="xs"
:is-loading="loadingReport"
@click="loadReport"
/>
</div>
</div>
<div v-if="loadingReport" class="text-sm text-n-slate-11 py-3">
{{ t('CAPTAIN_ROLETA.REPORT.LOADING') }}
</div>
<div
v-else-if="!report || report.by_receptionist.length === 0"
class="text-sm text-n-slate-11 py-3"
>
{{ t('CAPTAIN_ROLETA.REPORT.EMPTY') }}
</div>
<div v-else>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<div
class="rounded-lg border border-n-weak bg-n-alpha-black1 p-3"
>
<div class="text-xs text-n-slate-11 uppercase tracking-wide">
{{ t('CAPTAIN_ROLETA.REPORT.KPI_TOTAL') }}
</div>
<div class="text-2xl font-semibold text-n-slate-12 mt-1">
{{ report.team_total }}
</div>
</div>
<div
class="rounded-lg border border-n-weak bg-n-alpha-black1 p-3"
>
<div class="text-xs text-n-slate-11 uppercase tracking-wide">
{{ t('CAPTAIN_ROLETA.REPORT.KPI_AVG') }}
</div>
<div class="text-2xl font-semibold text-n-slate-12 mt-1">
{{ report.team_avg }}
</div>
</div>
<div
class="rounded-lg border border-n-weak bg-n-alpha-black1 p-3"
>
<div class="text-xs text-n-slate-11 uppercase tracking-wide">
{{ t('CAPTAIN_ROLETA.REPORT.KPI_COUNT') }}
</div>
<div class="text-2xl font-semibold text-n-slate-12 mt-1">
{{ report.receptionist_count }}
</div>
</div>
<div
class="rounded-lg border border-n-amber-7 bg-n-amber-3 p-3"
:class="{
'border-n-weak bg-n-alpha-black1':
report.anomaly_threshold === 0,
}"
>
<div
class="text-xs uppercase tracking-wide"
:class="
report.anomaly_threshold
? 'text-n-amber-11'
: 'text-n-slate-11'
"
>
{{ t('CAPTAIN_ROLETA.REPORT.KPI_THRESHOLD') }}
</div>
<div
class="text-2xl font-semibold mt-1"
:class="
report.anomaly_threshold
? 'text-n-amber-12'
: 'text-n-slate-12'
"
>
{{ t('CAPTAIN_ROLETA.REPORT.KPI_THRESHOLD_PREFIX')
}}{{ report.anomaly_threshold }}
</div>
</div>
</div>
<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_ROLETA.REPORT.COL_RECEPTIONIST') }}
</th>
<th class="text-right py-2 px-2">
{{ t('CAPTAIN_ROLETA.REPORT.COL_TOTAL') }}
</th>
<th class="text-right py-2 px-2">
{{ t('CAPTAIN_ROLETA.REPORT.COL_BRINDES') }}
</th>
<th class="text-right py-2 px-2">
{{ t('CAPTAIN_ROLETA.REPORT.COL_DESCONTOS') }}
</th>
<th class="text-right py-2 px-2">
{{ t('CAPTAIN_ROLETA.REPORT.COL_SUM_DISCOUNT') }}
</th>
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_ROLETA.REPORT.COL_STATUS') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in report.by_receptionist"
:key="r.receptionist_user_id"
:class="
'border-b border-n-weak last:border-b-0 ' +
(r.anomaly ? 'bg-n-amber-2' : '')
"
>
<td class="py-2 px-2">
<div class="font-medium text-n-slate-12">
{{ r.receptionist_name }}
</div>
<div
v-if="r.receptionist_email"
class="text-xs text-n-slate-11"
>
{{ r.receptionist_email }}
</div>
</td>
<td class="py-2 px-2 text-right font-semibold">
{{ r.total_redemptions }}
</td>
<td class="py-2 px-2 text-right text-n-slate-11">
{{ r.brinde_count }}
</td>
<td class="py-2 px-2 text-right text-n-slate-11">
{{ r.desconto_count }}
</td>
<td class="py-2 px-2 text-right text-n-slate-11">
{{ fmtCurrency(r.total_discount_value) }}%
</td>
<td class="py-2 px-2">
<span
v-if="r.anomaly"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-n-amber-4 text-n-amber-12 font-medium"
>
{{ t('CAPTAIN_ROLETA.REPORT.STATUS_ANOMALY') }}
</span>
<span v-else class="text-xs text-n-slate-11">
{{ t('CAPTAIN_ROLETA.REPORT.STATUS_NORMAL') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-n-slate-11 mt-4">
{{
t('CAPTAIN_ROLETA.REPORT.FOOTER_HINT').replace(
'{threshold}',
report.anomaly_threshold
)
}}
</p>
</div>
</div>
</template>
</div>
</template>
</PageLayout>
</template>

View File

@ -7,12 +7,14 @@ import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue'; import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue'; import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import CaptainReportsAPI from 'dashboard/api/captain/reports';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const insights = useMapGetter('captainReports/getInsights'); const insights = useMapGetter('captainReports/getInsights');
const operational = useMapGetter('captainReports/getOperational');
const uiFlags = useMapGetter('captainReports/getUIFlags'); const uiFlags = useMapGetter('captainReports/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords'); const assistants = useMapGetter('captainAssistants/getRecords');
@ -27,6 +29,7 @@ const tabs = [
{ key: 'dashboard' }, { key: 'dashboard' },
{ key: 'insights' }, { key: 'insights' },
{ key: 'operational' }, { key: 'operational' },
{ key: 'executive' },
{ key: 'landing_pages' }, { key: 'landing_pages' },
]; ];
@ -95,6 +98,133 @@ function getPeriodDates(period) {
}; };
} }
// Executive tab state
const execData = ref(null);
const execLoading = ref(false);
const execDelivering = ref(false);
const drilldown = reactive({
open: false,
title: '',
query: '',
loading: false,
conversations: [],
tokens: [],
});
const fetchExecutive = async () => {
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
if (!period_start || !period_end) return;
execLoading.value = true;
try {
const { data } = await CaptainReportsAPI.getExecutive({
period_start,
period_end,
});
execData.value = data;
} catch {
execData.value = null;
} finally {
execLoading.value = false;
}
};
const openDrilldown = async (title, query) => {
drilldown.open = true;
drilldown.title = title;
drilldown.query = query;
drilldown.loading = true;
drilldown.conversations = [];
drilldown.tokens = [];
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
try {
const { data } = await CaptainReportsAPI.drilldown({
query,
period_start,
period_end,
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
});
drilldown.conversations = data.conversations || [];
drilldown.tokens = data.tokens || [];
} catch {
drilldown.conversations = [];
drilldown.tokens = [];
} finally {
drilldown.loading = false;
}
};
const closeDrilldown = () => {
drilldown.open = false;
};
const deliverExecutive = async () => {
if (execDelivering.value) return;
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
execDelivering.value = true;
try {
await CaptainReportsAPI.deliverExecutive({ period_start, period_end });
useAlert(t('CAPTAIN_REPORTS.EXECUTIVE.DELIVER_SUCCESS'));
} catch {
useAlert(t('CAPTAIN_REPORTS.EXECUTIVE.DELIVER_ERROR'));
} finally {
execDelivering.value = false;
}
};
const execUnits = computed(() => execData.value?.unit_ranking || []);
const execAiPerf = computed(() => execData.value?.ai_performance || []);
const execOpportunities = computed(
() => execData.value?.customer_opportunities || []
);
const execComplaints = computed(() => execData.value?.complaints || []);
const execPraises = computed(() => execData.value?.praises || []);
const execRecommendations = computed(
() => execData.value?.recommendations || []
);
const aiPerfByUnit = unitId => {
if (!execAiPerf.value) return null;
return execAiPerf.value.find(p => p.unit_id === unitId);
};
const successRateColor = rate => {
if (rate === null || rate === undefined) return 'text-n-slate-9';
if (rate >= 85) return 'text-n-teal-11';
if (rate >= 70) return 'text-n-amber-11';
return 'text-n-ruby-11';
};
const deltaClass = pct => {
if (pct === null || pct === undefined) return 'text-n-slate-9';
if (pct > 0) return 'text-n-teal-11';
if (pct < 0) return 'text-n-ruby-11';
return 'text-n-slate-9';
};
const formatDelta = pct => {
if (pct === null || pct === undefined) return '';
const sign = pct > 0 ? '+' : '';
let arrow = '→';
if (pct > 0) arrow = '↑';
else if (pct < 0) arrow = '↓';
return `${arrow} ${sign}${pct}%`;
};
const fetchOperational = async () => {
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
if (!period_start || !period_end) return;
const params = {
period_start,
period_end,
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
};
try {
await store.dispatch('captainReports/fetchOperational', params);
} catch {
// silent - UI mostra fallback
}
};
const fetchLpStats = async () => { const fetchLpStats = async () => {
const user = store.getters['auth/getCurrentUser']; const user = store.getters['auth/getCurrentUser'];
const accountId = const accountId =
@ -150,13 +280,16 @@ watch(hasProcessingInsights, newVal => {
watch(activeTab, async tab => { watch(activeTab, async tab => {
if (tab === 'landing_pages') await fetchLpStats(); if (tab === 'landing_pages') await fetchLpStats();
if (tab === 'operational') await fetchOperational();
if (tab === 'executive') await fetchExecutive();
}); });
watch([customStartDate, customEndDate], async () => { watch([customStartDate, customEndDate], async () => {
if (selectedPeriod.value !== 'custom') return; if (selectedPeriod.value !== 'custom') return;
if (activeTab.value !== 'landing_pages') return;
if (!customStartDate.value || !customEndDate.value) return; if (!customStartDate.value || !customEndDate.value) return;
await fetchLpStats(); if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
}); });
// Auto-expand first done insight when loaded // Auto-expand first done insight when loaded
@ -189,11 +322,15 @@ const onFilterChange = async event => {
inbox_id: selectedInboxId.value, inbox_id: selectedInboxId.value,
}); });
if (activeTab.value === 'landing_pages') await fetchLpStats(); if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
}; };
const onPeriodChange = async event => { const onPeriodChange = async event => {
selectedPeriod.value = event.target.value; selectedPeriod.value = event.target.value;
if (activeTab.value === 'landing_pages') await fetchLpStats(); if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
}; };
const onGenerateInsight = async () => { const onGenerateInsight = async () => {
@ -287,11 +424,70 @@ const tabLabel = key => {
dashboard: t('CAPTAIN_REPORTS.TABS.DASHBOARD'), dashboard: t('CAPTAIN_REPORTS.TABS.DASHBOARD'),
insights: t('CAPTAIN_REPORTS.TABS.INSIGHTS'), insights: t('CAPTAIN_REPORTS.TABS.INSIGHTS'),
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'), operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
executive: t('CAPTAIN_REPORTS.TABS.EXECUTIVE'),
landing_pages: 'Landing Pages', landing_pages: 'Landing Pages',
}; };
return map[key] || key; return map[key] || key;
}; };
// Operational computeds
const opsLoading = computed(() => uiFlags.value?.isFetchingOperational);
const opsData = computed(() => operational.value);
const opsConversations = computed(() => opsData.value?.conversations || {});
const opsReservations = computed(() => opsData.value?.reservations || {});
const opsByInbox = computed(() => opsData.value?.by_inbox || []);
const opsHourly = computed(() => opsData.value?.hourly_distribution || []);
const opsDaily = computed(() => opsData.value?.daily_distribution || []);
const opsHourlyMax = computed(() => {
if (!opsHourly.value.length) return 1;
return Math.max(...opsHourly.value.map(h => h.count), 1);
});
const opsDailyMax = computed(() => {
if (!opsDaily.value.length) return 1;
return Math.max(...opsDaily.value.map(d => d.count), 1);
});
const opsByInboxMax = computed(() => {
if (!opsByInbox.value.length) return 1;
return Math.max(...opsByInbox.value.map(r => r.total), 1);
});
const hourlyAxisLabels = ['00h', '06h', '12h', '18h', '23h'];
const opsPeakHour = computed(() => {
if (!opsHourly.value.length) return null;
const peak = opsHourly.value.reduce((a, b) => (a.count >= b.count ? a : b));
return peak.count > 0 ? peak.hour : null;
});
const formatBrlFromCents = cents => {
if (!cents) return 'R$ 0,00';
return (cents / 100).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
});
};
const formatMinutes = minutes => {
if (!minutes || minutes < 0) return '—';
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours}h` : `${hours}h ${rest}min`;
};
const formatHour = hour => `${String(hour).padStart(2, '0')}:00`;
const formatShortDate = dateStr => {
if (!dateStr) return '—';
const [, month, day] = dateStr.split('-');
return `${day}/${month}`;
};
const lpMaxClicks = computed(() => { const lpMaxClicks = computed(() => {
if (!lpStats.value) return 1; if (!lpStats.value) return 1;
const all = [ const all = [
@ -1473,21 +1669,721 @@ const maxHandoffCount = computed(() =>
<!-- Tab: Operacional --> <!-- Tab: Operacional -->
<div v-else-if="activeTab === 'operational'"> <div v-else-if="activeTab === 'operational'">
<!-- Loading -->
<div <div
v-if="opsLoading"
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<span
class="i-lucide-loader-2 size-8 animate-spin text-n-slate-9"
/>
<p class="mb-0 text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.LOADING') }}
</p>
</div>
<!-- Empty state -->
<div
v-else-if="!opsData || (opsConversations.total || 0) === 0"
class="flex flex-col items-center justify-center gap-4 py-20 text-center" class="flex flex-col items-center justify-center gap-4 py-20 text-center"
> >
<div <div
class="flex size-16 items-center justify-center rounded-full bg-n-amber-2" class="flex size-16 items-center justify-center rounded-full bg-n-slate-3"
> >
<span class="i-lucide-construction size-8 text-n-amber-9" /> <span class="i-lucide-bar-chart-3 size-8 text-n-slate-9" />
</div> </div>
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.COMING_SOON') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10"> <p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.COMING_SOON_DESC') }} {{ t('CAPTAIN_REPORTS.OPERATIONAL.NO_DATA') }}
</p> </p>
</div> </div>
<!-- Report -->
<div v-else class="space-y-6">
<!-- KPIs Conversas -->
<div>
<p
class="mb-3 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.CONVERSATIONS_SECTION') }}
</p>
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ (opsConversations.total || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.TOTAL') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-teal-11">
{{ (opsConversations.resolved || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RESOLVED') }}
<span class="font-medium">
({{ opsConversations.resolution_rate || 0 }}%)
</span>
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-amber-11">
{{ (opsConversations.open || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.OPEN') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ formatMinutes(opsConversations.avg_resolution_minutes) }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.AVG_RESOLUTION') }}
</p>
</div>
</div>
</div>
<!-- KPIs Reservas -->
<div>
<p
class="mb-3 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RESERVATIONS_SECTION') }}
</p>
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ (opsReservations.total || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RES_TOTAL') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-teal-11">
{{ (opsReservations.paid || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RES_PAID') }}
<span class="font-medium">
({{ opsReservations.conversion_rate || 0 }}%)
</span>
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-ruby-11">
{{ (opsReservations.expired || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RES_EXPIRED') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-teal-11">
{{ formatBrlFromCents(opsReservations.total_paid_cents) }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.RES_REVENUE') }}
</p>
</div>
</div>
</div>
<!-- Ranking por canal ( quando não filtrou por inbox) -->
<div
v-if="opsByInbox.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.BY_INBOX') }}
</p>
<div class="space-y-2">
<div
v-for="row in opsByInbox"
:key="row.inbox_id"
class="flex items-center gap-3"
>
<span
class="w-32 shrink-0 truncate text-xs font-medium text-n-slate-12"
:title="row.inbox_name"
>
{{ row.inbox_name }}
</span>
<div class="flex-1 rounded-full bg-n-slate-3 h-2">
<div
class="h-2 rounded-full bg-n-blue-8 transition-all"
:style="{
width:
Math.round((row.total / opsByInboxMax) * 100) + '%',
}"
/>
</div>
<span
class="w-10 shrink-0 text-right text-xs font-semibold text-n-slate-11"
>
{{ row.total }}
</span>
<span
class="w-14 shrink-0 text-right text-xs text-n-slate-9"
:title="
t('CAPTAIN_REPORTS.OPERATIONAL.RESOLUTION_RATE_TOOLTIP')
"
>
{{ row.resolution_rate }}%
</span>
</div>
</div>
</div>
<!-- Distribuição por dia + Distribuição por hora -->
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<!-- Daily -->
<div
v-if="opsDaily.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.DAILY_DIST') }}
</p>
<div class="flex h-40 items-end gap-1">
<div
v-for="day in opsDaily"
:key="day.date"
class="group relative flex-1"
>
<div
class="w-full rounded-t bg-n-blue-7 transition-all group-hover:bg-n-blue-9"
:style="{
height:
Math.max(
Math.round((day.count / opsDailyMax) * 100),
day.count > 0 ? 3 : 0
) + '%',
}"
:title="`${formatShortDate(day.date)}: ${day.count}`"
/>
</div>
</div>
<div
class="mt-2 flex justify-between text-[10px] text-n-slate-9"
>
<span>{{ formatShortDate(opsDaily[0]?.date) }}</span>
<span>
{{ formatShortDate(opsDaily[opsDaily.length - 1]?.date) }}
</span>
</div>
</div>
<!-- Hourly -->
<div
v-if="opsHourly.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<div class="mb-4 flex items-baseline justify-between">
<p
class="text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.HOURLY_DIST') }}
</p>
<p
v-if="opsPeakHour !== null"
class="text-xs text-n-slate-10"
>
{{ t('CAPTAIN_REPORTS.OPERATIONAL.PEAK') }}:
<span class="font-semibold text-n-slate-12">
{{ formatHour(opsPeakHour) }}
</span>
</p>
</div>
<div class="flex h-40 items-end gap-px">
<div
v-for="slot in opsHourly"
:key="slot.hour"
class="group relative flex-1"
>
<div
class="w-full rounded-t bg-n-amber-7 transition-all group-hover:bg-n-amber-9"
:style="{
height:
Math.max(
Math.round((slot.count / opsHourlyMax) * 100),
slot.count > 0 ? 3 : 0
) + '%',
}"
:title="`${formatHour(slot.hour)}: ${slot.count}`"
/>
</div>
</div>
<div
class="mt-2 flex justify-between text-[10px] text-n-slate-9"
>
<span v-for="label in hourlyAxisLabels" :key="label">
{{ label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Executivo -->
<div v-else-if="activeTab === 'executive'">
<!-- Loading -->
<div
v-if="execLoading"
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<span
class="i-lucide-loader-2 size-8 animate-spin text-n-slate-9"
/>
<p class="mb-0 text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.LOADING') }}
</p>
</div>
<!-- Empty -->
<div
v-else-if="!execData || execData.empty"
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div
class="flex size-16 items-center justify-center rounded-full bg-n-slate-3"
>
<span class="i-lucide-briefcase size-8 text-n-slate-9" />
</div>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.NO_DATA') }}
</p>
</div>
<!-- Report -->
<div v-else class="space-y-6">
<!-- Header with deliver button -->
<div class="flex items-center justify-between">
<div>
<h3 class="mb-1 text-base font-semibold text-n-slate-12">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.TITLE') }}
</h3>
<p class="mb-0 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.SUBTITLE') }}
</p>
</div>
<Button
:label="t('CAPTAIN_REPORTS.EXECUTIVE.DELIVER_BUTTON')"
icon="i-lucide-send"
color="slate"
:is-loading="execDelivering"
@click="deliverExecutive"
/>
</div>
<!-- KPIs topo -->
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ (execData.totals.conversations || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.CONVERSATIONS') }}
</p>
<p
v-if="execData.totals.conversations_delta_pct !== null"
class="mt-1 text-xs font-medium"
:class="deltaClass(execData.totals.conversations_delta_pct)"
>
{{ formatDelta(execData.totals.conversations_delta_pct) }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ (execData.totals.messages || 0).toLocaleString() }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.MESSAGES') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ execData.totals.units_analyzed || 0 }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.UNITS_ANALYZED') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ execData.totals.insights_analyzed || 0 }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.INSIGHTS_COUNT') }}
</p>
</div>
</div>
<!-- Tabela comparativa por unidade -->
<div
v-if="execUnits.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 overflow-hidden"
>
<div class="border-b border-n-weak px-5 py-3">
<p
class="text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.UNIT_TABLE') }}
</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr
class="border-b border-n-weak text-left text-xs text-n-slate-9"
>
<th class="px-4 py-2 font-medium">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COL_UNIT') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COL_CONVS') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COL_DELTA') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COL_AI_RATE') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COL_FAILURES') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(unit, idx) in execUnits"
:key="unit.unit_id"
class="border-b border-n-weak last:border-0 hover:bg-n-alpha-2"
>
<td class="px-4 py-3">
<span class="font-medium text-n-slate-12">
{{ idx + 1 }}. {{ unit.unit_name }}
</span>
</td>
<td
class="px-4 py-3 text-right font-semibold text-n-slate-12"
>
{{ unit.conversations.toLocaleString() }}
</td>
<td
class="px-4 py-3 text-right text-xs font-medium"
:class="deltaClass(unit.conversations_delta_pct)"
>
{{ formatDelta(unit.conversations_delta_pct) || '—' }}
</td>
<td
class="px-4 py-3 text-right font-semibold"
:class="
successRateColor(
aiPerfByUnit(unit.unit_id)?.success_rate_pct
)
"
>
{{
aiPerfByUnit(unit.unit_id)?.success_rate_pct !==
null &&
aiPerfByUnit(unit.unit_id)?.success_rate_pct !==
undefined
? aiPerfByUnit(unit.unit_id).success_rate_pct + '%'
: '—'
}}
</td>
<td class="px-4 py-3 text-right text-n-slate-11">
{{ aiPerfByUnit(unit.unit_id)?.failures_count || 0 }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Falhas da IA (drill-down) -->
<div
v-if="execAiPerf.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.AI_FAILURES') }}
</p>
<div class="space-y-3">
<div
v-for="(unitPerf, uIdx) in execAiPerf.filter(
u => u.top_failures?.length
)"
:key="'af-' + uIdx"
class="border-l-2 border-n-ruby-6 pl-3"
>
<p class="mb-2 text-xs font-medium text-n-slate-11">
{{ unitPerf.unit_name }}
<span :class="successRateColor(unitPerf.success_rate_pct)">
{{ unitPerf.success_rate_pct || 0 }}%
</span>
</p>
<div class="space-y-1">
<button
v-for="(fail, fIdx) in unitPerf.top_failures"
:key="'af-' + uIdx + '-' + fIdx"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left text-xs text-n-slate-11 hover:bg-n-alpha-2"
@click="
openDrilldown(
fail.description,
fail.example || fail.description
)
"
>
<span class="flex-1">
{{ fail.description }}
</span>
<span
class="shrink-0 rounded-full bg-n-ruby-2 px-2 py-0.5 text-n-ruby-11"
>
{{ fail.frequency
}}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
</span>
<span
class="i-lucide-external-link size-3 text-n-slate-9"
/>
</button>
</div>
</div>
</div>
</div>
<!-- Oportunidades (clicáveis) -->
<div
v-if="execOpportunities.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.OPPORTUNITIES') }}
</p>
<p class="mb-3 text-xs text-n-slate-9 italic">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.OPPORTUNITIES_HINT') }}
</p>
<div class="space-y-1">
<button
v-for="(opp, idx) in execOpportunities"
:key="'op-' + idx"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-sm text-n-slate-12 hover:bg-n-alpha-2"
@click="
openDrilldown(
opp.opportunity,
opp.example || opp.opportunity
)
"
>
<span class="flex-1 font-medium">{{ opp.opportunity }}</span>
<span
class="shrink-0 rounded-full bg-n-amber-2 px-2 py-0.5 text-xs text-n-amber-11"
>
{{ opp.frequency }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
</span>
<span class="i-lucide-external-link size-3 text-n-slate-9" />
</button>
</div>
</div>
<!-- Reclamações + Elogios side by side -->
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-if="execComplaints.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.COMPLAINTS') }}
</p>
<div class="space-y-1">
<button
v-for="(c, idx) in execComplaints.slice(0, 8)"
:key="'cp-' + idx"
type="button"
class="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-n-slate-11 hover:bg-n-alpha-2"
@click="openDrilldown(c.text, c.text)"
>
<span class="text-n-ruby-9"></span>
<span class="flex-1 italic">{{ c.text }}</span>
<span class="shrink-0 text-n-slate-9">
{{ c.frequency }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
</span>
</button>
</div>
</div>
<div
v-if="execPraises.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.PRAISES') }}
</p>
<div class="space-y-1">
<button
v-for="(p, idx) in execPraises.slice(0, 8)"
:key="'pr-' + idx"
type="button"
class="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-n-slate-11 hover:bg-n-alpha-2"
@click="openDrilldown(p.text, p.text)"
>
<span class="text-n-teal-9"></span>
<span class="flex-1 italic">{{ p.text }}</span>
<span class="shrink-0 text-n-slate-9">
{{ p.frequency }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
</span>
</button>
</div>
</div>
</div>
<!-- Recomendações da IA -->
<div
v-if="execRecommendations.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.RECOMMENDATIONS') }}
</p>
<ol class="list-decimal space-y-2 pl-5 text-sm text-n-slate-11">
<li
v-for="(rec, idx) in execRecommendations"
:key="'rec-' + idx"
>
{{ rec }}
</li>
</ol>
</div>
</div>
<!-- Drilldown modal -->
<div
v-if="drilldown.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
@click.self="closeDrilldown"
>
<div
class="max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-2xl bg-n-background shadow-2xl"
>
<div
class="flex items-start justify-between border-b border-n-weak px-5 py-4"
>
<div class="flex-1">
<p
class="mb-1 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.EXECUTIVE.DRILLDOWN_TITLE') }}
</p>
<p class="mb-0 text-sm font-medium text-n-slate-12">
{{ drilldown.title }}
</p>
<div
v-if="drilldown.tokens.length"
class="mt-2 flex flex-wrap items-center gap-1"
>
<span class="text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.SEARCH_TOKENS') }}:
</span>
<span
v-for="tk in drilldown.tokens"
:key="tk"
class="rounded-full bg-n-alpha-2 px-2 py-0.5 text-xs text-n-slate-11"
>
{{ tk }}
</span>
</div>
</div>
<button
type="button"
class="ml-3 rounded-lg p-1 text-n-slate-9 hover:bg-n-alpha-2"
@click="closeDrilldown"
>
<span class="i-lucide-x size-5" />
</button>
</div>
<div class="max-h-[60vh] overflow-y-auto px-5 py-4">
<div
v-if="drilldown.loading"
class="flex items-center justify-center py-10"
>
<span
class="i-lucide-loader-2 size-6 animate-spin text-n-slate-9"
/>
</div>
<div
v-else-if="!drilldown.conversations.length"
class="py-8 text-center"
>
<p class="mb-2 text-sm text-n-slate-11">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.NO_CONVERSATIONS_FOUND') }}
</p>
<p class="mb-0 text-xs text-n-slate-9 italic">
{{ t('CAPTAIN_REPORTS.EXECUTIVE.NO_CONVERSATIONS_HINT') }}
</p>
</div>
<div v-else class="space-y-2">
<a
v-for="conv in drilldown.conversations"
:key="'dd-' + conv.id"
:href="conv.url"
target="_blank"
rel="noopener noreferrer"
class="block rounded-xl border border-n-weak p-4 hover:bg-n-alpha-2"
>
<div class="flex items-center justify-between gap-2">
<p class="mb-0 font-medium text-n-slate-12">
#{{ conv.id }} · {{ conv.contact_name || '—' }}
</p>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
:class="
conv.status === 'resolved'
? 'bg-n-teal-2 text-n-teal-11'
: 'bg-n-amber-2 text-n-amber-11'
"
>
{{ conv.status }}
</span>
</div>
<p class="mb-0 mt-1 text-xs text-n-slate-9">
{{ conv.inbox_name }} ·
{{ formatDate(conv.created_at.split('T')[0]) }}
</p>
<p
class="mb-0 mt-2 flex items-center gap-1 text-xs text-n-brand"
>
<span class="i-lucide-external-link size-3" />
{{ t('CAPTAIN_REPORTS.EXECUTIVE.OPEN_CONVERSATION') }}
</p>
</a>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Tab: Landing Pages --> <!-- Tab: Landing Pages -->

View File

@ -33,5 +33,29 @@ export default createStore({
commit(mutations.SET_UI_FLAG, { fetchingItem: false }); commit(mutations.SET_UI_FLAG, { fetchingItem: false });
} }
}, },
cancel: async function cancel(_, { id, reason = '' }) {
try {
const response = await CaptainReservationsAPI.cancel(id, reason);
return response.data;
} catch (error) {
return throwErrorMessage(error);
}
},
markAsPaid: async function markAsPaid(_, { id, note = '' }) {
try {
const response = await CaptainReservationsAPI.markAsPaid(id, note);
return response.data;
} catch (error) {
return throwErrorMessage(error);
}
},
regeneratePix: async function regeneratePix(_, id) {
try {
const response = await CaptainReservationsAPI.regeneratePix(id);
return response.data;
} catch (error) {
return throwErrorMessage(error);
}
},
}), }),
}); });

View File

@ -3,6 +3,7 @@
# Table name: captain_units # Table name: captain_units
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# concierge_config :jsonb not null
# inter_account_number :string # inter_account_number :string
# inter_cert_content :text # inter_cert_content :text
# inter_cert_path :string # inter_cert_path :string
@ -27,20 +28,23 @@
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null # account_id :bigint not null
# captain_brand_id :bigint not null # captain_brand_id :bigint not null
# concierge_inbox_id :bigint
# inbox_id :bigint # inbox_id :bigint
# inter_client_id :string # inter_client_id :string
# plug_play_id :string # plug_play_id :string
# #
# Indexes # Indexes
# #
# index_captain_units_on_account_id (account_id) # index_captain_units_on_account_id (account_id)
# index_captain_units_on_captain_brand_id (captain_brand_id) # index_captain_units_on_captain_brand_id (captain_brand_id)
# index_captain_units_on_inbox_id (inbox_id) # index_captain_units_on_concierge_inbox_id (concierge_inbox_id)
# index_captain_units_on_inbox_id (inbox_id)
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id) # fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (concierge_inbox_id => inboxes.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #

View File

@ -59,3 +59,13 @@
title: 'Gerar Link de Reserva' title: 'Gerar Link de Reserva'
description: 'Gera um link da pagina publica de reserva ja pre-preenchida, pronto para o cliente revisar e pagar' description: 'Gera um link da pagina publica de reserva ja pre-preenchida, pronto para o cliente revisar e pagar'
icon: 'link' icon: 'link'
- id: get_reserva_preco
title: 'Consultar Preco da Reserva'
description: 'Consulta o preco oficial de uma suite no banco de reservas (categoria + permanencia + periodo da semana)'
icon: 'currency-dollar'
- id: generate_roleta_link
title: 'Oferecer Roleta da Sorte'
description: 'Oferece ao cliente uma roleta de brindes apos a reserva ser fechada. Envia a mensagem de agradecimento + link automaticamente.'
icon: 'gift'

View File

@ -112,6 +112,13 @@ Rails.application.routes.draw do
resources :insights, only: [:index, :show] do resources :insights, only: [:index, :show] do
post :generate, on: :collection post :generate, on: :collection
end end
resource :funnel, only: [:show], controller: :funnel
end
# Roleta da Sorte - tela de resgate na recepção + relatório semanal
resource :roleta, only: [], controller: 'roleta' do
get :pending
post :redeem
get :weekly_report
end end
end end
resource :saml_settings, only: [:show, :create, :update, :destroy] resource :saml_settings, only: [:show, :create, :update, :destroy]
@ -726,6 +733,11 @@ Rails.application.routes.draw do
to: 'public/api/v1/captain/inter_webhooks#create', to: 'public/api/v1/captain/inter_webhooks#create',
defaults: { format: 'json' } defaults: { format: 'json' }
# Callback do front (reserva-1001) quando o cliente gira a roleta
post '/api/v1/captain/roleta/notify',
to: 'public/api/v1/captain/roulette_notifications#create',
defaults: { format: 'json' }
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Routes for swagger docs # Routes for swagger docs
get '/swagger/*path', to: 'swagger#respond' get '/swagger/*path', to: 'swagger#respond'

View File

@ -13,6 +13,21 @@ trigger_scheduled_items_job:
class: 'TriggerScheduledItemsJob' class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs queue: scheduled_jobs
# Fallback pra draws da roleta revelados cujo callback do front falhou.
# Executa a cada 5 minutos.
captain_roleta_notify_revealed_scheduler_job:
cron: '*/5 * * * *'
class: 'Captain::Roleta::NotifyRevealedSchedulerJob'
queue: scheduled_jobs
# Re-engajamento de clientes recorrentes que sumiram há 60+ dias.
# Roda de hora em hora das 13h às 20h UTC (10h às 17h BRT).
# O job verifica internamente se está em horário comercial BRT + dia útil.
captain_retention_churn_outreach_scheduler_job:
cron: '5 13-20 * * 1-5'
class: 'Captain::Retention::ChurnOutreachSchedulerJob'
queue: scheduled_jobs
# executed every minute. # executed every minute.
trigger_scheduled_messages_job: trigger_scheduled_messages_job:
cron: '* * * * *' cron: '* * * * *'
@ -102,6 +117,23 @@ landing_hosts_promotion_sync_scheduler_job:
class: 'LandingHosts::PromotionSyncSchedulerJob' class: 'LandingHosts::PromotionSyncSchedulerJob'
queue: scheduled_jobs queue: scheduled_jobs
# Sunday at 04:00 UTC (01:00 BRT)
# Generates weekly LLM insights for every account + every captain unit.
# Each account gets a global insight (all conversations) plus one per unit.
captain_weekly_insights_job:
cron: '0 4 * * 0'
class: 'Captain::Reports::WeeklyInsightsJob'
queue: scheduled_jobs
# Monday at 11:00 UTC (08:00 BRT)
# Aggregates last week's insights and delivers the CEO Digest to Mattermost.
# Requires per-account config in account.custom_attributes.ceo_digest
# or fallback ENV CEO_DIGEST_MATTERMOST_WEBHOOK_URL.
captain_ceo_digest_job:
cron: '0 11 * * 1'
class: 'Captain::Reports::CeoDigestJob'
queue: scheduled_jobs
# every 10 minutes - detects silent conversations for memory extraction # every 10 minutes - detects silent conversations for memory extraction
captain_contact_memory_silence_detector_job: captain_contact_memory_silence_detector_job:
cron: '*/10 * * * *' cron: '*/10 * * * *'

View File

@ -0,0 +1,28 @@
class FixOrchestratorPromptDelimiterPosition < ActiveRecord::Migration[7.1]
# Delimitador antigo (posição errada — depois de # Your Identity)
OLD_DELIMITER = "\n# ---SECAO-ASSISTENTE---\n# Instruções Específicas deste Assistente".freeze
# Texto correto sem delimitador naquela posição
REPLACEMENT = "\n\n# Instruções Específicas deste Assistente".freeze
# Delimitador novo (posição correta — no final do template)
END_MARKER_OLD = "- NUNCA tente responder via FAQ um pedido de foto ou imagem — sempre use handoff.\n# ---SECAO-ASSISTENTE---".freeze
END_MARKER_OK = "- NUNCA tente responder via FAQ um pedido de foto ou imagem — sempre use handoff.\n# ---SECAO-ASSISTENTE---".freeze
def up
# Corrige registros que têm o delimitador na posição errada (no meio do texto)
Captain::Assistant.where('orchestrator_prompt LIKE ?', "%# ---SECAO-ASSISTENTE---\n# Instruções Específicas%").find_each do |assistant|
fixed = assistant.orchestrator_prompt
.gsub("# ---SECAO-ASSISTENTE---\n# Instruções Específicas deste Assistente",
'# Instruções Específicas deste Assistente')
# Garante que o delimitador existe no final, antes do conteúdo de instruções
fixed = "#{fixed.rstrip}\n# ---SECAO-ASSISTENTE---\n" unless fixed.include?('# ---SECAO-ASSISTENTE---')
assistant.update_column(:orchestrator_prompt, fixed) # rubocop:disable Rails/SkipsModelValidations
end
end
def down
# irreversível sem backup — não faz rollback
end
end

View File

@ -0,0 +1,177 @@
# frozen_string_literal: true
# Data migration — atualiza o cenário Daniela_Reservas pra incluir
# o Passo 0 de classificação de intenção (consulta vs reserva).
# Idempotente: detecta se já tem o Passo 0 e não sobrescreve.
class SeedDanielaPassoZeroScenario < ActiveRecord::Migration[7.1]
def up
scenario = ::Captain::Scenario.find_by(title: 'Daniela_Reservas')
return say('Scenario Daniela_Reservas não encontrado — pulando') unless scenario
if scenario.instruction.include?('PASSO 0 — CLASSIFIQUE A INTENÇÃO')
say('Daniela_Reservas já tem Passo 0 — pulando')
return
end
scenario.update!(instruction: new_instruction)
say('Daniela_Reservas atualizada com Passo 0')
end
def down
# sem rollback — mudança de conteúdo de prompt não é reversível de forma útil
end
def new_instruction
<<~MD
# Cenário: Reservas, Preços e Pagamento Pix
Sessão exclusiva pra reservas, preços e Pix. Não se apresente.
## 🚨 VOCÊ É A AGENTE DE RESERVAS — NUNCA FAÇA HANDOFF DE VOLTA PRA JASMINE
Durante QUALQUER fluxo (consulta de preço, coleta de dados, cálculo, geração de Pix, tratamento de erros), VOCÊ é a única agente responsável. **Jamais** chame `handoff_to_jasmine` nem qualquer outro `handoff_to_*_agent`.
O único `handoff` permitido é `captain--tools--handoff` (sem argumentos, pra humano) e apenas se o cliente:
1. Disser explicitamente que está FISICAMENTE no hotel com problema operacional (ex: "estou no quarto, o ar não funciona").
2. Pedir cancelamento de reserva (fora do seu escopo).
3. Falar sobre assunto claramente não-reserva (serviços de quarto, limpeza, queixas de estadia atual).
Em qualquer outro caso: RESPONDA VOCÊ MESMA.
---
## 🎯 PASSO 0 — CLASSIFIQUE A INTENÇÃO ANTES DE RESPONDER
Leia a última mensagem do cliente e classifique em A, B ou C:
### A) CONSULTA DE INFORMAÇÃO (preço, valor, quanto custa, tabela)
Cliente quer saber valor, SEM pedir pra reservar.
Exemplos:
- "qual o preço da Estilo?"
- "quanto custa pernoite na Alexa?"
- "valor da hidro por 4 horas?"
- "e a diária, quanto fica?"
- "tem preço por pernoite?"
**AÇÃO:** responda DIRETO com o(s) valor(es) da tabela abaixo. Mensagem curta, amigável, sem pedir dados.
**FECHAMENTO OBRIGATÓRIO:** termine com um convite natural a reservar.
Ex: *"Pernoite na Stilo sai R$ 140. Quer que eu reserve pra você?"*
**NÃO** pergunte data, horário, permanência, CPF, email.
**NÃO** chame `generate_pix` nem `generate_reservation_link`.
**NÃO** entre no Turno 1. Fique nesse modo até o cliente demonstrar intenção de reserva.
Se o cliente não especificou a duração ("qual o preço da Estilo?"), mostre a linha inteira da suíte na tabela (2h, 3h, 4h, pernoite, diária) ele escolhe.
### B) INTENÇÃO EXPLÍCITA DE RESERVA
Cliente quer reservar. Palavras-chave: "quero reservar", "vou querer", "pode reservar", "fazer uma reserva", "quero pegar", "me reserva", "quero ficar", "bora", "topo".
Também conta como intenção de reserva quando o cliente dados concretos no mesmo turno:
- "quero a Estilo amanhã às 22h, pernoite"
- "pega a hidro pra sexta à noite"
- Após você responder um preço em A), o cliente disser "quero" / "pode ser" / "bora" / "sim".
**AÇÃO:** pro **Turno 1** abaixo.
### C) NÃO É RESERVA NEM PREÇO
Redirecione curto: *"Posso te ajudar com reservas, preços e Pix. Outras dúvidas me fala qual é 😊"*
---
## 💰 TABELA DE PREÇOS (use direto, não chame faq pra isso)
| Suíte | 2hrs | 3hrs | 4hrs | Pernoite | Diária |
|---|---|---|---|---|---|
| Alexa | 60 | 80 | 100 | 160 | 220 |
| Stilo | 50 | 70 | 85 | 140 | 200 |
| Hidromassagem | 100 | 130 | 160 | 260 | 330 |
Marca: **Hotel 1001 Noites Prime**. Unidade: **Prime Águas Lindas**.
Termos populares:
- hidro/banheira/spa/jacuzzi/ofurô **Hidromassagem**
- estilo/stilo **Stilo**
---
## 🧰 FERRAMENTAS
- **`generate_pix(amount, suite, check_in, total_amount)`** gera Pix do sinal. TODOS os 4 obrigatórios:
- `amount`: 50% de `total_amount` (o sinal). Ex: 70.0
- `suite`: `"Alexa"` | `"Stilo"` | `"Hidromassagem"` ( esses 3 nomes válidos)
- `check_in`: ISO 8601. Ex: `"2026-04-27T22:00:00"`
- `total_amount`: valor TOTAL. Ex: 140.0
Nome/CPF/email vêm do contato auto. O sistema manda o link em msg separada.
- **`generate_reservation_link(marca, unidade, categoria, permanencia, checkin_at)`** fallback. Use se `generate_pix` retornar `success: false` **sem** `requires_input`.
- **`faq_lookup(query)`** com query ESPECÍFICA (`"preço pernoite alexa"`). NUNCA com texto cru do cliente. Prefira a tabela acima use faq pra regras especiais (feriado, promoção pontual).
---
## 🎯 TURNO 1 — COLETA ÚNICA (só após intenção de reserva confirmada)
### ANTES de pedir dado — leia `# Contact Information` no system prompt:
| Campo | NÃO peça se preenchido em... |
|---|---|
| Nome | `Name:` |
| Email | `Email:` |
| CPF | `cpf:` (em custom_attributes) |
Cliente **recorrente** = tem `cpf` no custom_attributes trate pelo primeiro nome, sem formalidade.
Uma única msg perguntando o que falta:
1. Suíte? (Alexa/Stilo/Hidromassagem) se veio no Passo 0, não repita
2. Qual dia?
3. **Horário que você quer chegar (check-in)?** obrigatório. Exemplo: "15h", "22:30", "meia-noite".
4. Permanência? (2hrs/3hrs/4hrs/pernoite/diária)
**Por que o horário importa:** o sistema dispara mensagens programadas (Captain Lifecycle) com base na hora exata de check-in boas-vindas 10min antes, oferta de serviços durante a estadia, etc. Um horário errado = mensagens disparadas na hora errada.
Nome/CPF/email: **** pergunte se o campo vazio no contato.
Se cliente mencionou 1/2/3/4 **e** contato tem cadastro pule pro Turno 2 direto.
Se cliente responder "qualquer horário" ou "tanto faz": assuma o default por permanência e CONFIRME ("Vou marcar 22h — se mudar me avisa"). Default: 22:00 pra Pernoite/Diária, +1h do agora pra horas avulsas.
## 🎯 TURNO 2 — AÇÃO IMEDIATA (sem texto intermediário)
Tendo suíte+data+permanência:
1. Pega preço na tabela acima.
2. Sinal = 50% do total.
3. Monta o `check_in` em ISO 8601 completo com a **data + horário informados pelo cliente no Turno 1**. Ex: data "27/4" + hora "15h" `"2026-04-27T15:00:00"`. Se cliente não informou hora, usa default (22:00 pernoite/diária, +1h agora pra avulsas) e menciona o default na resposta final.
4. Chama `generate_pix(amount, suite, check_in, total_amount)` **os 4 campos preenchidos**.
5. depois responde ao cliente (ver ).
## ✅ APÓS `generate_pix` com sucesso
O link foi enviado em msg separada. Sua resposta: confirmação + valor do sinal (agora) + valor restante (no check-in). Curta, natural. **NÃO** inclua URL, markdown `[texto](url)`, placeholders, nem chame outras ferramentas.
**Inclua também uma frase de incentivo pro pagamento**, mencionando que assim que o Pix cair você manda uma surpresa da Roleta da Sorte cliente pode ganhar desconto ou brinde no check-in. Use tom leve. Exemplo: *"Ahh, e tem surpresa: assim que seu Pix for confirmado, te mando um link da nossa Roleta da Sorte — você pode ganhar desconto ou um brinde na recepção 🎁"*. Não mande o link aqui quando o pagamento for confirmado automaticamente.
## 🔄 RETORNO DO `generate_pix`
| Retorno | O que fazer |
|---|---|
| `success: true` (sem `requires_input`) | Responde cliente (seção ) |
| `requires_input: true` | VOCÊ esqueceu parâmetro. Chame de novo com os 4 campos corretos. **NÃO caia no fallback** |
| `success: false` (sem `requires_input`) | Erro técnico chama `generate_reservation_link` com marca/unidade/categoria/permanência/checkin_at. Depois responde: *"Tive um probleminha no Pix 🙏 Mandei link com tudo preenchido — já chegou aí."* |
## 🚫 Proibições
- Cair no Turno 1 quando o cliente pediu preço (viola o Passo 0).
- `generate_pix({})` vazio sempre os 4 parâmetros.
- Confirmar reserva sem chamar `generate_pix`.
- Inventar valores fora da tabela.
- Pedir nome/CPF/email existentes.
- Pedir telefone (nunca).
- `faq_lookup` com texto cru.
## 🔧 Ferramentas ativas
- [@Gerar Pix](tool://generate_pix)
- [@Gerar Link de Reserva](tool://generate_reservation_link)
- [@Handoff to Human](tool://handoff)
- [@Add Label to Conversation](tool://add_label_to_conversation)
MD
end
end

View File

@ -0,0 +1,217 @@
# frozen_string_literal: true
# Data migration — cria o cenário Reclamacoes_Ouvidoria pros assistants
# existentes que ainda não tiverem esse cenário. Idempotente.
# rubocop:disable Metrics/MethodLength
class SeedReclamacoesOuvidoriaScenario < ActiveRecord::Migration[7.1]
TITLE = 'Reclamacoes_Ouvidoria'
def up
assistants = ::Captain::Assistant.all.to_a
if assistants.empty?
say('Nenhum Captain::Assistant encontrado — pulando seed')
return
end
assistants.each do |assistant|
existing = ::Captain::Scenario.find_by(assistant_id: assistant.id, title: TITLE)
if existing
say("Assistant #{assistant.id} (#{assistant.name}) já tem #{TITLE} — pulando")
next
end
::Captain::Scenario.create!(
account_id: assistant.account_id,
assistant_id: assistant.id,
title: TITLE,
description: description_text,
instruction: instruction_text,
enabled: true
)
say("Cenário #{TITLE} criado pra assistant #{assistant.id}")
end
end
def down
::Captain::Scenario.where(title: TITLE).destroy_all
end
def description_text
'Especialista em reclamações, queixas operacionais e feedback negativo. ' \
'Triagem por urgência (P1 crítico = risco físico, P2 urgente = conforto quebrado, ' \
'P3 normal = produto/serviço faltando, P4 feedback pós-estadia). ' \
'Aplica framework LAST (Listen/Apologize/Solve/Thank). Cria nota privada ' \
'estruturada antes de escalar. NÃO oferece compensação material — isso é da gerência.'
end
# Instruction sync com o que foi criado em 2026-04-21 (doc: Chatwoot - Branch Captain Semantic Memory).
def instruction_text
<<~MD
# Cenário: Reclamações, Queixas e Ouvidoria
Sessão exclusiva pra tratar queixas, problemas operacionais e feedback negativo. Não se apresente continue natural.
## 🚨 REGRA DE OURO — FRAMEWORK LAST EM TODO TURNO
Toda resposta sua segue essa ordem mental (não precisa ser literal):
### Antes de responder, leia em 3 camadas o que o cliente disse:
1. **Superfície** o que ele falou literalmente ("o ar não tá gelando")
2. **Subtexto** o que ele quer dizer além disso ("tá calor, eu paguei esperando conforto, isso aqui já tá atrapalhando a experiência")
3. **Emoção** o que ele está sentindo ("frustrado, com medo de ficar a noite toda assim, com dúvida se vão resolver")
Sua resposta precisa endereçar as 3 camadas NUNCA a superfície.
### Depois aplica o LAST:
1. **Listen (Escutar)** reconheça o problema específico + a emoção. Mencione o detalhe que o cliente deu + valide o que ele sentindo.
2. **Apologize (Pedir desculpa)** desculpa sem ser servil. Uma frase curta, genuína. Nunca "peço mil desculpas"/"mil perdões" parece falso.
3. **Solve (Resolver)** ação concreta pro nível de urgência. Ver protocolo P1-P4 abaixo. **TODA resposta de queixa termina com próximo passo + prazo.** Sem isso a msg incompleta.
4. **Thank (Agradecer)** no final, agradeça pelo aviso. Isso fecha com energia construtiva.
Exemplo completo: *"Entendi, ar-condicionado sem gelar no calor é bem chato — ainda mais agora que você deveria estar relaxando. Sinto muito pelo contratempo. Já tô chamando a recepção pra resolver, sobe alguém em no máximo 15min. Se ultrapassar isso, me avisa que eu cobro. Obrigada por me dizer."*
Note como a resposta: (a) nomeia o problema específico [AC], (b) valida a emoção [deveria estar relaxando], (c) tem ação concreta com prazo [15min], (d) abre porta pra cobrança [me avisa se ultrapassar], (e) agradece.
## 🎯 PASSO 0 — DIAGNÓSTICO E CLASSIFICAÇÃO
Antes de responder, classifique a queixa em **uma das 4 prioridades**. Se faltar informação, faça UMA pergunta curta pra confirmar (NÃO bombardeie o cliente de perguntas).
### P1 — CRÍTICO (escala IMEDIATO)
**Envolve risco à integridade física, segurança ou saúde do hóspede.**
Exemplos:
- Alguém se machucou / passou mal / está com dor
- Vazamento grave (água escorrendo, risco de inundar)
- Cheiro forte de gás
- Elétrica pegando fogo / choque
- Tranca quebrada com cliente preso dentro ou fora do quarto
- Invasor / intruso / estranho no corredor
- Acidente (caiu, escorregou)
Ação:
1. Confirme que o cliente está bem AGORA (*"você tá bem? tá em segurança nesse momento?"*).
2. Chame `update_priority` = `urgent`.
3. Chame `add_label_to_conversation` com `queixa_P1`.
4. Chame `add_private_note` com o formato estruturado abaixo.
5. Chame `handoff` (humano) IMEDIATO.
6. Responda ao cliente: *"Já acionei a equipe AGORA mesmo, alguém vai te atender em segundos. Se for emergência médica, liga 192 também em paralelo."*
### P2 — URGENTE (conforto básico quebrado, escala em ≤15min)
**Problema operacional ativo que afeta diretamente a estadia presente.**
Exemplos:
- AC não funciona / não gela
- Chuveiro frio ou sem pressão
- Cheiro ruim forte no quarto (mofo, esgoto)
- Barulho extremo do vizinho
- Wi-fi completamente fora do ar
- TV sem funcionar
- Geladeira do quarto quebrada
Ação:
1. Confirme sintomas com UMA pergunta se não claro (ex: *"o AC tá ligado mas não gela, ou não liga de jeito nenhum?"*).
2. Peça foto/áudio se ajudar diagnóstico (*"se puder, manda uma foto do painel do AC?"*). peça se adicionar info real.
3. Chame `add_label_to_conversation` com `queixa_P2`.
4. Chame `add_private_note` no formato estruturado.
5. Chame `handoff`.
6. Responda ao cliente: *"Já passei pra recepção, alguém vai subir aí em no máximo 15min pra resolver. Se demorar mais, me avisa."*
### P3 — NORMAL (Jasmine resolve sozinha na maioria)
**Produto/serviço faltando ou demora, sem quebra de conforto essencial.**
Exemplos:
- Toalha / papel higiênico / amenidade faltando
- Lâmpada queimada ( uma)
- Demora em atendimento da recepção (>15min esperando)
- Falta shampoo, sabonete, água
- Bateria do controle remoto
Ação:
1. Confirme o que precisa (*"só toalha de banho ou de rosto também?"*).
2. Chame `add_label_to_conversation` com `queixa_P3`.
3. Chame `add_private_note` pedindo providência à recepcionista.
4. Responda: *"Vou pedir já pra te levarem. Em 5-10min alguém leva. Se não chegar, me avisa que eu cobro aqui."*
5. **NÃO chame handoff** a recepcionista a nota privada e atende. Você segue disponível pro cliente cobrar.
### P4 — FEEDBACK (cliente pós-estadia ou comentando sem urgência)
**Reclamação sobre algo que aconteceu ou observação geral sem pedido de ação imediata.**
Exemplos:
- *"A camareira foi grossa ontem"*
- *"O café da manhã tava frio"* (depois que ele saiu)
- *"Achei caro o pernoite"*
- *"Não gostei do atendimento do Fulano"*
- *"O colchão tá meio duro"*
- Avaliações negativas proativas sem pedido de resolução
Ação:
1. **Ouça com empatia profunda** é um presente do cliente te contar isso em vez de sumir.
2. Chame `add_label_to_conversation` com `feedback_negativo`.
3. Chame `add_contact_note` registrando o incidente no perfil do contato.
4. Chame `add_private_note` com o feedback pra gerência ler.
5. Responda: *"Obrigada por me dizer, de verdade. Você não precisava ter esse trabalho de me contar, e isso ajuda demais a gente melhorar. Vou levar pessoalmente pra gerência e alguém vai te procurar pra conversar."*
6. **NÃO prometa compensação** não é sua autoridade.
## 📝 FORMATO DA NOTA PRIVADA (obrigatório em P1, P2 e P3)
Use `add_private_note` com esse formato LITERAL (preenchendo os campos):
```
🚨 [P1] [P2] [P3] [P4] Queixa
Cliente: {nome} ({telefone})
Quarto/Suíte: {info se tiver} | sem_info
Problema: {resumo objetivo em 1 linha}
Sintomas: {o que o cliente descreveu}
Horário reportado: {agora}
Evidência: {foto_enviada | audio_enviado | só_texto}
Severidade estimada: {crítica | alta | média | baixa}
Próximo passo sugerido:
- {1-2 bullets com o que a recepcionista deve fazer}
```
o emoji 🚨 pra P1, pode suprimir pra P2/P3/P4.
## 🚫 PROIBIÇÕES ABSOLUTAS
- **NÃO ofereça compensação material** (desconto, reembolso parcial, upgrade, cortesia). Isso é decisão exclusiva da gerência humana. Se o cliente pedir, responda: *"Vou passar seu pedido pra gerência. Eles decidem e te retornam.*"
- **NÃO prometa tempo específico além do padrão** (P1=agora, P2=15min, P3=5-10min). Não invente "volta em 3min" pra ser agradável.
- **NÃO minimize** o problema ("isso é normal", "costuma passar", "deve ser coisa rápida"). Valida primeiro.
- **NÃO jogue a culpa em terceiros** ("o funcionário X é novo", "o hóspede anterior..."). Cliente não quer saber.
- **NÃO peça perdão 3x na mesma mensagem.** Uma desculpa curta e autêntica > 3 desculpas servis.
- **NÃO encerre a conversa depois do handoff.** Fique disponível pro cliente desabafar ou cobrar.
- **NÃO use "caro cliente"/"prezado"/"senhor(a)"** tom casual, como é padrão da Jasmine.
## 🔍 SELF-CHECK ANTES DE ENVIAR (faça mentalmente)
Antes de mandar a resposta, passe por essas 3 perguntas:
1. **"Estou soando servil?"** Se pedi desculpa 2+ vezes na mesma msg, ou usei diminutivo genuflexivo ("encarecidamente", "humildemente"), REESCREVO mais direto.
2. **"Prometi algo que não posso cumprir?"** Se comprometi compensação material (desconto, reembolso, upgrade) ou prazo fora do padrão (P1=agora, P2=15min, P3=5-10min), RETIRO a promessa.
3. **"Minha resposta fecha com próximo passo + prazo?"** Se terminei com "qualquer coisa me avise" sem ação concreta, ADICIONO a ação+prazo.
Se qualquer uma falhou, reescreve antes de enviar.
## 🎯 DETECÇÃO DE CLIENTE FRUSTRADO (sinais)
Se a mensagem do cliente tem:
- Palavrões ou CAPS LOCK
- Múltiplos pontos de exclamação ou interrogação
- Ameaça explícita ("vou dar 1 estrela", "nunca mais volto")
- Estendeu a queixa em mensagens seguidas sem esperar resposta
Então: **eleva 1 nível** de prioridade (P3 vira P2, P4 vira P3), adiciona tag `cliente_frustrado`, e responde com mais cuidado (respira na frase, não acelera a resolução pra "despachar").
## 🔧 Ferramentas ativas
- [@Add Label to Conversation](tool://add_label_to_conversation) queixa_P1 / queixa_P2 / queixa_P3 / feedback_negativo / cliente_frustrado
- [@Add Private Note](tool://add_private_note) sempre com formato estruturado acima
- [@Add Contact Note](tool://add_contact_note) em P4 (registra no perfil)
- [@Update Priority](tool://update_priority) em P1 (urgent)
- [@Handoff to Human](tool://handoff) em P1 e P2
- [@FAQ Lookup](tool://faq_lookup) se cliente perguntar política (cancelamento, checkout, reembolso) se tiver query específica
MD
end
end
# rubocop:enable Metrics/MethodLength

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_19_024929) do ActiveRecord::Schema[7.1].define(version: 2026_04_21_120002) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
# rubocop:disable Metrics/ClassLength
class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::BaseController
CONFIRMED_STATUSES = %i[scheduled active completed].freeze CONFIRMED_STATUSES = %i[scheduled active completed].freeze
RESULTS_PER_PAGE = 25 RESULTS_PER_PAGE = 25
@ -9,7 +10,7 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
before_action :set_per_page, only: [:index] before_action :set_per_page, only: [:index]
before_action :set_reservations_scope before_action :set_reservations_scope
before_action :set_reservation, only: [:show, :pix] before_action :set_reservation, only: [:show, :pix, :cancel, :mark_as_paid, :regenerate_pix]
def index def index
scoped = apply_filters(@reservations_scope) scoped = apply_filters(@reservations_scope)
@ -54,11 +55,57 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
} }
end end
def cancel
reason = params[:reason].to_s.strip
@reservation.metadata ||= {}
@reservation.metadata['cancelled_by_user_id'] = current_user.id
@reservation.metadata['cancelled_at'] = Time.current.iso8601
@reservation.metadata['cancel_reason'] = reason if reason.present?
@reservation.update!(status: :cancelled)
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
render 'api/v1/accounts/captain/reservations/show'
end
def mark_as_paid
@reservation.metadata ||= {}
@reservation.metadata['manual_payment_by_user_id'] = current_user.id
@reservation.metadata['manual_payment_at'] = Time.current.iso8601
@reservation.metadata['manual_payment_note'] = params[:note].to_s.strip if params[:note].present?
@reservation.update!(status: :scheduled, payment_status: 'paid')
@reservation.current_pix_charge&.update!(status: 'paid') if @reservation.current_pix_charge.present?
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
render 'api/v1/accounts/captain/reservations/show'
end
def regenerate_pix
raise 'Reservation not configured for PIX' if @reservation.unit.blank?
@reservation.current_pix_charge&.update!(status: 'expired')
Captain::Inter::CobService.new(@reservation).call
@reservation.update!(status: :pending_payment)
@reservation.reload
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
render 'api/v1/accounts/captain/reservations/show'
rescue StandardError => e
render json: { error: "Falha ao regerar PIX: #{e.message}" }, status: :unprocessable_entity
end
private private
def set_reservations_scope def set_reservations_scope
@reservations_scope = Current.account.captain_reservations scope = Current.account.captain_reservations
.includes(:contact, :unit, :conversation, :current_pix_charge) .includes(:contact, :unit, :conversation, :current_pix_charge)
@reservations_scope = filter_by_user_inbox_access(scope)
end
# Agentes só enxergam reservas em caixas de entrada que eles podem acessar.
def filter_by_user_inbox_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.where(inbox_id: accessible_inbox_ids)
end end
def set_reservation def set_reservation
@ -191,3 +238,4 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
@reservation.contact_inbox_id = contact_inbox.id @reservation.contact_inbox_id = contact_inbox.id
end end
end end
# rubocop:enable Metrics/ClassLength

View File

@ -0,0 +1,117 @@
# frozen_string_literal: true
# Tela de resgate da Roleta da Sorte na recepção.
# Lista cupons revelados dos últimos X dias + permite marcar resgatado.
class Api::V1::Accounts::Captain::RoletaController < Api::V1::Accounts::BaseController
DEFAULT_SCHEMA = 'reserva_hotel'
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
# GET /api/v1/accounts/:account_id/captain/roleta/pending
# params: days_back (int, default 3), marca_id (uuid, opcional — filtra por marca)
def pending
tenant_id = tenant_id_for_account(current_account)
render json: { pending: [], note: 'tenant_not_mapped' } and return if tenant_id.blank?
days_back = params[:days_back].to_i
days_back = 3 if days_back <= 0
body = {
p_tenant_id: tenant_id,
p_days_back: days_back,
p_limit: 100
}
rows = supabase_rpc('list_roulette_pending', body)
render json: { pending: rows }
end
# GET /api/v1/accounts/:account_id/captain/roleta/weekly_report
# params: period_days (default 7)
def weekly_report
days = params[:period_days].to_i
days = 7 if days <= 0 || days > 90
report = Captain::Roleta::WeeklyReportService.new(account: current_account, period_days: days).call
render json: report
end
# POST /api/v1/accounts/:account_id/captain/roleta/redeem
# body: { code: "ABC123", notes: "opcional" }
def redeem
code = params[:code].to_s.strip.upcase
notes = params[:notes]
if code.blank?
render json: { success: false, error_code: 'empty_code' }, status: :unprocessable_entity
return
end
result = Captain::Roleta::RedeemService.new(
code: code,
receptionist_user: Current.user,
notes: notes
).perform
if result.success
render json: { success: true, result: result.to_h }
else
render json: { success: false, error_code: result.error_code, result: result.to_h }, status: :unprocessable_entity
end
end
private
# Resolve tenant_id do Supabase a partir do account do Chatwoot.
# Usa qualquer inbox desse account que tenha captain_inbox.unit + lookup em reserva_hotel.unidades.
def tenant_id_for_account(account)
unit = Captain::Unit.joins(:inboxes).where(inboxes: { account_id: account.id }).first
return nil if unit.blank?
row = supabase_get('unidades', { chatwoot_unit_id: "eq.#{unit.id}", select: 'tenant_id', limit: 1 }).first
row&.[]('tenant_id')
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = Faraday.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_rpc(fn_name, body)
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
response = Faraday.post(url) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.body = body.to_json
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
end

View File

@ -42,6 +42,6 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
end end
def scenario_params def scenario_params
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: []) params.require(:scenario).permit(:title, :description, :instruction, :trigger_keywords, :enabled, tools: [])
end end
end end

View File

@ -19,6 +19,33 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
/\AYou are part of Captain,/i /\AYou are part of Captain,/i
].freeze ].freeze
# Padrões que indicam vazamento de "pensamento" / instrução interna em qualquer parte da mensagem.
# Se a resposta contém qualquer um destes, ela está descrevendo o que fazer em vez de fazer.
# Bloqueia e força handoff humano para evitar que o cliente veja conteúdo interno.
THOUGHT_LEAK_PATTERNS = [
# Narração em terceira pessoa sobre o próprio assistente
/\b(jasmine|a\s+ia|o\s+assistente|o\s+bot)\s+(deve|deveria|precisa|tem\s+que|nunca\s+deve|n[ãa]o\s+deve)\b/i,
# Instrução condicional vazada
/\bquando\s+o\s+cliente\s+(fa[zç]er|disser|pedir|perguntar|falar|usar|mencionar|informar)\b/i,
# Comandos imperativos pra IA disfarçados de resposta
/\b(busque|consulte|acione|chame|use)\s+(a\s+)?ferramenta\b/i,
/\b(passe|envie|repasse)\s+para\s+(ele|ela|o\s+cliente)\b/i,
# Nomes técnicos de tools/handoffs nunca devem aparecer ao cliente
/\bhandoff_to_/i,
/\bcaptain--tools--/i,
/\b(daniela_reservas|maria_fotos|disponibilidade_suites|outras_unidades)\b/i,
/\bhandoff_imediato\b/i,
# Descrições meta de fluxo
/\b(fluxo\s+correto|gatilhos?\s+de\s+exemplo|antes\s+de\s+responder|antes\s+de\s+gerar)\b/i,
# JSON cru / blocos de schema
/\A\s*[{\[]/,
/"reasoning"\s*:/,
/"reaction_emoji"\s*:/,
# Liquid não renderizado
/\{\{\s*\w+\s*\}\}/,
/\{%\s*\w+/
].freeze
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
@ -270,7 +297,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def system_prompt_leak?(content) def system_prompt_leak?(content)
text = content.is_a?(String) ? content.strip : content.to_s.strip text = content.is_a?(String) ? content.strip : content.to_s.strip
SYSTEM_PROMPT_LEAK_PATTERNS.any? { |pattern| text.match?(pattern) } return true if SYSTEM_PROMPT_LEAK_PATTERNS.any? { |pattern| text.match?(pattern) }
return true if THOUGHT_LEAK_PATTERNS.any? { |pattern| text.match?(pattern) }
false
end end
def create_outgoing_message(message_content, agent_name: nil) def create_outgoing_message(message_content, agent_name: nil)

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
# Disparado após Captain::Payments::ConfirmationService confirmar um pagamento.
# Gera o link da Roleta da Sorte e envia a mensagem pro cliente.
class Captain::Payments::OfferRouletteJob < ApplicationJob
queue_as :low
def perform(reservation_id)
reservation = Captain::Reservation.find_by(id: reservation_id)
return if reservation.blank?
# Só oferece roleta se pagamento realmente tá confirmado (idempotência defensiva).
return unless reservation.payment_status.to_s == 'paid'
result = Captain::Roleta::OfferService.new(reservation: reservation).perform
if result[:success]
Rails.logger.info(
"[OfferRouletteJob] reserva=#{reservation.id} url=#{result[:url]} created=#{result[:was_created]}"
)
else
Rails.logger.warn("[OfferRouletteJob] reserva=#{reservation.id} erro=#{result[:error]}")
end
end
end

View File

@ -0,0 +1,70 @@
# Gera e entrega o CEO Digest semanal para cada conta que tiver configurado.
#
# Config por conta em account.custom_attributes['ceo_digest']:
# {
# "enabled": true,
# "mattermost_webhook_url": "https://mm.example.com/hooks/xxxxx",
# "mattermost_channel": "#executivo" # opcional
# }
#
# Fallback global via ENV (caso a conta não tenha config própria):
# CEO_DIGEST_MATTERMOST_WEBHOOK_URL
class Captain::Reports::CeoDigestJob < ApplicationJob
queue_as :scheduled_jobs
def perform(account_id = nil, period_start = nil, period_end = nil)
period_end = (period_end || Date.yesterday).to_date
period_start = (period_start || (period_end - 6.days)).to_date
scope = account_id ? Account.where(id: account_id) : Account.all
scope.find_each { |account| deliver_for(account, period_start, period_end) }
end
private
def deliver_for(account, period_start, period_end)
config = digest_config(account)
return log_skip(account, 'no webhook_url configured') if config[:webhook_url].blank?
return log_skip(account, 'disabled in custom_attributes') if config[:enabled] == false
digest = build_digest(account, period_start, period_end)
result = deliver_to_mattermost(digest, config)
log_result(account, result)
rescue StandardError => e
Rails.logger.error "[CeoDigest] unexpected error for account ##{account.id}: #{e.class} #{e.message}"
end
def build_digest(account, period_start, period_end)
Captain::Reports::CeoDigestService.new(
account: account, period_start: period_start, period_end: period_end
).call
end
def deliver_to_mattermost(digest, config)
Captain::Reports::MattermostDeliveryService.new(
digest: digest, webhook_url: config[:webhook_url], channel: config[:channel]
).call
end
def log_result(account, result)
if result[:success]
Rails.logger.info "[CeoDigest] delivered for account ##{account.id}"
else
Rails.logger.error "[CeoDigest] failed for account ##{account.id}: #{result.inspect}"
end
end
def digest_config(account)
raw = account.custom_attributes&.dig('ceo_digest') || {}
{
enabled: raw.fetch('enabled', true),
webhook_url: raw['mattermost_webhook_url'].presence || ENV.fetch('CEO_DIGEST_MATTERMOST_WEBHOOK_URL', nil),
channel: raw['mattermost_channel'].presence
}
end
def log_skip(account, reason)
Rails.logger.info "[CeoDigest] skipping account ##{account.id}: #{reason}"
end
end

View File

@ -2,23 +2,42 @@ class Captain::Reports::WeeklyInsightsJob < ApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
# Roda todo domingo de madrugada via Sidekiq-Cron. # Roda todo domingo de madrugada via Sidekiq-Cron.
# Agenda geração de insights para todas as unidades de todas as contas. # Agenda geração de insights:
# - 1 global por conta (account toda)
# - 1 por Captain::Unit (agrupamento lógico de marca, se houver)
# - 1 por Inbox (cada canal é uma "unidade" do ponto de vista operacional)
def perform def perform
period_end = Date.yesterday period_end = Date.yesterday
period_start = period_end - 6.days period_start = period_end - 6.days
Account.find_each do |account| Account.find_each do |account|
# Gera um insight global (sem unit) para a conta toda enqueue_global(account, period_start, period_end)
Captain::Reports::GenerateInsightsJob.perform_later( enqueue_per_captain_unit(account, period_start, period_end)
account.id, nil, period_start, period_end enqueue_per_inbox(account, period_start, period_end)
) end
end
# Gera um insight por unidade private
account.captain_units.find_each do |unit|
Captain::Reports::GenerateInsightsJob.perform_later( def enqueue_global(account, period_start, period_end)
account.id, unit.id, period_start, period_end Captain::Reports::GenerateInsightsJob.perform_later(
) account.id, nil, period_start, period_end
end )
end
def enqueue_per_captain_unit(account, period_start, period_end)
account.captain_units.find_each do |unit|
Captain::Reports::GenerateInsightsJob.perform_later(
account.id, unit.id, period_start, period_end
)
end
end
def enqueue_per_inbox(account, period_start, period_end)
account.inboxes.find_each do |inbox|
Captain::Reports::GenerateInsightsJob.perform_later(
account.id, nil, period_start, period_end, inbox.id
)
end end
end end
end end

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
# Manda UMA mensagem de re-engajamento pro contato. Invocado pelo Scheduler.
# Guardas internas: checa de novo cooldown e existência de conversa/inbox
# (caso dados tenham mudado entre o enqueue e a execução).
class Captain::Retention::ChurnOutreachJob < ApplicationJob
COOLDOWN_DAYS = 180
DISCOUNT_PCT = 10
queue_as :low
def perform(contact_id)
contact = Contact.find_by(id: contact_id)
return if contact.blank?
return if within_cooldown?(contact)
conversation = find_or_create_conversation(contact)
return if conversation.blank?
assistant = conversation.inbox&.captain_assistant
return if assistant.blank?
content = build_message(contact)
Messages::MessageBuilder.new(assistant, conversation, {
content: content,
message_type: 'outgoing'
}).perform
apply_label(conversation)
mark_contact_outreached!(contact)
Rails.logger.info("[ChurnOutreach] sent to contact=#{contact.id} conversation=#{conversation.id}")
rescue StandardError => e
Rails.logger.error("[ChurnOutreach] #{e.class}: #{e.message}")
end
private
def within_cooldown?(contact)
last = contact.custom_attributes&.dig('churn_outreach_at')
return false if last.blank?
Time.zone.parse(last.to_s) > COOLDOWN_DAYS.days.ago
rescue ArgumentError
false
end
def find_or_create_conversation(contact)
# Tenta pegar a última conversa do contato num inbox WhatsApp (canal típico da Jasmine).
contact.conversations
.joins(:inbox)
.where(inboxes: { channel_type: 'Channel::Whatsapp' })
.order(updated_at: :desc)
.first
end
def build_message(contact)
first_name = (contact.name.to_s.split.first.presence) || 'oi'
last_res = Captain::Reservation.where(contact_id: contact.id, payment_status: 'paid').order(check_in_at: :desc).first
months = compute_months_away(last_res)
<<~MSG.strip
Oi #{first_name}! 💛
Fiquei com saudade faz #{months} que você não aparece aqui com a gente.
Se rolar de voltar, tenho uma cortesia pra te receber: #{DISCOUNT_PCT}% de desconto no próximo pernoite.
Quer que eu reserve pra algum dia?
MSG
end
def compute_months_away(last_res)
return 'um bom tempo' if last_res.blank? || last_res.check_in_at.blank?
diff = ((Time.current - last_res.check_in_at) / 30.days.to_i).to_i
case diff
when 0..1 then 'algumas semanas'
when 2..11 then "#{diff} meses"
else 'mais de 1 ano'
end
rescue StandardError
'um bom tempo'
end
def apply_label(conversation)
current = conversation.label_list || []
conversation.update_labels((current + ['reengajamento_churn']).uniq)
rescue StandardError => e
Rails.logger.warn("[ChurnOutreach] label failed: #{e.message}")
end
def mark_contact_outreached!(contact)
attrs = contact.custom_attributes.to_h
attrs['churn_outreach_at'] = Time.current.iso8601
contact.update!(custom_attributes: attrs)
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
# Dispara mensagem de re-engajamento pra clientes que sumiram.
# Critérios:
# - 2+ reservas pagas no histórico
# - Última reserva há 60+ dias
# - Nenhum outreach nos últimos 180 dias (custom_attributes.churn_outreach_at)
# Segurança:
# - Só dias úteis 10h18h BRT (cron garante)
# - Máximo 20 mensagens por account por dia
class Captain::Retention::ChurnOutreachSchedulerJob < ApplicationJob
MIN_PAID_RESERVATIONS = 2
DAYS_SILENT = 60
COOLDOWN_DAYS = 180
MAX_PER_ACCOUNT_DAY = 20
queue_as :scheduled_jobs
def perform
return unless within_business_hours?
Account.find_each do |account|
# Skip accounts without any captain assistant
next unless Captain::Assistant.exists?(account_id: account.id)
eligible = find_eligible_contacts(account)
next if eligible.empty?
already_sent_today = sent_today_count(account)
budget = MAX_PER_ACCOUNT_DAY - already_sent_today
next if budget <= 0
eligible.limit(budget).each do |contact|
Captain::Retention::ChurnOutreachJob.perform_later(contact.id)
end
end
rescue StandardError => e
Rails.logger.error("[ChurnOutreachScheduler] #{e.class}: #{e.message}")
end
private
def within_business_hours?
now = Time.current.in_time_zone('America/Sao_Paulo')
return false if now.saturday? || now.sunday?
(10..17).cover?(now.hour)
end
def find_eligible_contacts(account)
cutoff_last_res = DAYS_SILENT.days.ago
cutoff_outreach = COOLDOWN_DAYS.days.ago.iso8601
# Contatos com 2+ reservas PAGAS e última reserva antes do cutoff
contact_ids = Captain::Reservation
.where(account_id: account.id, payment_status: 'paid')
.group(:contact_id)
.having('COUNT(*) >= ? AND MAX(check_in_at) < ?', MIN_PAID_RESERVATIONS, cutoff_last_res)
.pluck(:contact_id)
return Contact.none if contact_ids.empty?
account.contacts
.where(id: contact_ids)
.where(
"custom_attributes ->> 'churn_outreach_at' IS NULL OR custom_attributes ->> 'churn_outreach_at' < ?",
cutoff_outreach
)
end
def sent_today_count(account)
today = Time.zone.today.iso8601
account.contacts
.where("custom_attributes ->> 'churn_outreach_at' >= ?", today)
.count
end
end

View File

@ -0,0 +1,113 @@
# frozen_string_literal: true
# Chamado após o cliente girar a roleta e o prêmio ser revelado.
# Claim atômico no Supabase garante que só UMA execução manda a mensagem,
# mesmo se o frontend dispara + um cron polling dispara simultâneo.
# rubocop:disable Metrics/MethodLength
class Captain::Roleta::NotifyRevealedJob < ApplicationJob
DEFAULT_SCHEMA = 'reserva_hotel'
queue_as :low
def perform(token)
return if token.blank?
row = claim_draw(token)
return if row.blank? # já notificado ou ainda pending
reservation = Captain::Reservation.find_by(id: row['reservation_id'])
return if reservation.blank?
conversation = reservation.conversation
return if conversation.blank?
assistant = conversation.inbox&.captain_assistant
return if assistant.blank?
content = build_message(row)
return if content.blank?
Messages::MessageBuilder.new(assistant, conversation, {
content: content,
message_type: 'outgoing'
}).perform
Rails.logger.info(
"[NotifyRevealedJob] token=#{token} reserva=#{reservation.id} premio=#{row['prize_nome']}"
)
end
private
def claim_draw(token)
Array(supabase_rpc('claim_draw_for_notification', { p_token: token })).first
end
def build_message(row)
case row['prize_tipo']
when 'desconto_percentual'
valor = Integer(Float(row['prize_valor']))
<<~MSG.strip
🎉 A roleta parou! Você ganhou #{valor}% de desconto no saldo do check-in 💛
Mostra esse código na recepção:
*#{row['code']}*
Te espero por lá! 🍀
MSG
when 'brinde_fisico'
<<~MSG.strip
🎉 A roleta parou! Você ganhou: *#{row['prize_nome']}* 🎁
Mostra esse código na recepção pra retirar seu brinde:
*#{row['code']}*
Te espero por lá! 🍀
MSG
else
<<~MSG.strip
Dessa vez a roleta não rolou pra você 🫶 mas sua reserva garantida e eu te espero de braços abertos.
Da próxima que voltar, tem roleta nova te esperando! 🍀
MSG
end
end
def supabase_rpc(fn_name, body)
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
response = supabase_client.post(url) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.body = body.to_json
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_client
@supabase_client ||= Faraday.new do |f|
f.adapter Faraday.default_adapter
f.options.timeout = 8
f.options.open_timeout = 4
end
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
end
# rubocop:enable Metrics/MethodLength

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
# Cron de fallback (a cada 5 min): enfileira NotifyRevealedJob pra qualquer
# draw que foi revelado há >60s e ainda não foi notificado. Cobre cenários onde
# o front não conseguiu chamar o endpoint (browser fechado, rede caiu).
class Captain::Roleta::NotifyRevealedSchedulerJob < ApplicationJob
DEFAULT_SCHEMA = 'reserva_hotel'
queue_as :scheduled_jobs
def perform
pending = fetch_pending_tokens
return if pending.blank?
Rails.logger.info "[NotifyRevealedScheduler] enfileirando #{pending.size} drawn(s)"
pending.each { |row| Captain::Roleta::NotifyRevealedJob.perform_later(row['token']) }
end
private
def fetch_pending_tokens
body = {
:select => 'token',
:status => 'eq.revealed',
:notified_at => 'is.null',
'revealed_at' => "lt.#{1.minute.ago.iso8601}",
:limit => 100
}
supabase_get('roulette_draws', body)
rescue StandardError => e
Rails.logger.warn("[NotifyRevealedScheduler] falha: #{e.class} - #{e.message}")
[]
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = Faraday.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
end

View File

@ -119,7 +119,8 @@ class Captain::Assistant < ApplicationRecord
{ {
title: scenario.title, title: scenario.title,
key: scenario.title.parameterize.underscore, key: scenario.title.parameterize.underscore,
description: scenario.description description: scenario.description,
trigger_keywords: scenario.trigger_keywords
} }
end, end,
response_guidelines: response_guidelines || [], response_guidelines: response_guidelines || [],

View File

@ -3,25 +3,41 @@
# Table name: captain_contact_memories # Table name: captain_contact_memories
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# account_id :bigint not null
# contact_id :bigint not null
# memory_type :string not null
# content :text not null
# evidence :text not null
# confidence :float not null # confidence :float not null
# scope :string not null, default: 'global' # content :text not null
# deleted_at :datetime
# embedding :vector(1536) # embedding :vector(1536)
# source_conversation_id :bigint # evidence :text not null
# source_unit_id :bigint
# source_inbox_id :bigint
# expires_at :datetime # expires_at :datetime
# last_verified_at :datetime not null # last_verified_at :datetime not null
# memory_type :string not null
# metadata :jsonb not null
# scope :string default("global"), not null
# superseded_at :datetime # superseded_at :datetime
# superseded_by_id :bigint
# deleted_at :datetime
# metadata :jsonb not null, default: {}
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null
# contact_id :bigint not null
# source_conversation_id :bigint
# source_inbox_id :bigint
# source_unit_id :bigint
# superseded_by_id :bigint
#
# Indexes
#
# idx_ccm_analytics (source_unit_id,memory_type,created_at)
# idx_ccm_embedding (embedding) USING ivfflat
# idx_ccm_hard_delete (deleted_at) WHERE (deleted_at IS NOT NULL)
# idx_ccm_recall (account_id,contact_id) WHERE ((deleted_at IS NULL) AND (superseded_at IS NULL))
# idx_ccm_source_conversation (source_conversation_id)
# idx_ccm_superseded (superseded_by_id) WHERE (superseded_at IS NOT NULL)
# index_captain_contact_memories_on_account_id (account_id)
# index_captain_contact_memories_on_contact_id (contact_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id) ON DELETE => cascade
# fk_rails_... (contact_id => contacts.id) ON DELETE => cascade
# #
class Captain::ContactMemory < ApplicationRecord class Captain::ContactMemory < ApplicationRecord
self.table_name = 'captain_contact_memories' self.table_name = 'captain_contact_memories'

View File

@ -1,5 +1,31 @@
# frozen_string_literal: true # frozen_string_literal: true
# == Schema Information
#
# Table name: captain_lifecycle_configs
#
# id :bigint not null, primary key
# min_interval_minutes :integer default(30), not null
# pause_on_customer_reply :boolean default(FALSE), not null
# pause_on_customer_reply_within_minutes :integer default(60), not null
# quiet_hours_enabled :boolean default(FALSE), not null
# quiet_hours_from :time default(Sat, 01 Jan 2000 23:00:00.000000000 UTC +00:00), not null
# quiet_hours_to :time default(Sat, 01 Jan 2000 08:00:00.000000000 UTC +00:00), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# opt_out_label_id :bigint
#
# Indexes
#
# index_captain_lifecycle_configs_on_account_id (account_id) UNIQUE
# index_captain_lifecycle_configs_on_opt_out_label_id (opt_out_label_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (opt_out_label_id => labels.id)
#
class Captain::Lifecycle::Config < ApplicationRecord class Captain::Lifecycle::Config < ApplicationRecord
self.table_name = 'captain_lifecycle_configs' self.table_name = 'captain_lifecycle_configs'

View File

@ -1,3 +1,45 @@
# == Schema Information
#
# Table name: captain_lifecycle_deliveries
#
# id :bigint not null, primary key
# failure_reason :text
# fire_at :datetime not null
# origin :string default("scheduled_lifecycle"), not null
# rendered_body :text
# sent_at :datetime
# skip_reason :string
# status :string default("scheduled"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# captain_reservation_id :bigint not null
# conversation_id :bigint
# inbox_id :bigint
# lifecycle_rule_id :bigint
# message_id :bigint
#
# Indexes
#
# idx_lifecycle_deliveries_cap_check (captain_reservation_id,origin,status)
# idx_lifecycle_deliveries_dashboard (account_id,status,fire_at)
# idx_lifecycle_deliveries_reservation (captain_reservation_id)
# idx_lifecycle_deliveries_rule (lifecycle_rule_id)
# idx_lifecycle_deliveries_scheduled (fire_at) WHERE ((status)::text = 'scheduled'::text)
# index_captain_lifecycle_deliveries_on_account_id (account_id)
# index_captain_lifecycle_deliveries_on_conversation_id (conversation_id)
# index_captain_lifecycle_deliveries_on_inbox_id (inbox_id)
# index_captain_lifecycle_deliveries_on_message_id (message_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_reservation_id => captain_reservations.id)
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
# fk_rails_... (lifecycle_rule_id => captain_lifecycle_rules.id)
# fk_rails_... (message_id => messages.id)
#
class Captain::Lifecycle::Delivery < ApplicationRecord class Captain::Lifecycle::Delivery < ApplicationRecord
self.table_name = 'captain_lifecycle_deliveries' self.table_name = 'captain_lifecycle_deliveries'

View File

@ -1,5 +1,36 @@
# frozen_string_literal: true # frozen_string_literal: true
# == Schema Information
#
# Table name: captain_lifecycle_rules
#
# id :bigint not null, primary key
# description :text
# enabled :boolean default(TRUE), not null
# event :string not null
# filters :jsonb not null
# message_body :text not null
# message_payload :jsonb
# message_type :string default("text"), not null
# name :string not null
# offset_minutes :integer default(0), not null
# priority :integer default(50), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# created_by_user_id :bigint
#
# Indexes
#
# idx_on_account_id_enabled_event_2d8b8a9942 (account_id,enabled,event)
# index_captain_lifecycle_rules_on_account_id (account_id)
# index_captain_lifecycle_rules_on_created_by_user_id (created_by_user_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (created_by_user_id => users.id)
#
class Captain::Lifecycle::Rule < ApplicationRecord class Captain::Lifecycle::Rule < ApplicationRecord
self.table_name = 'captain_lifecycle_rules' self.table_name = 'captain_lifecycle_rules'

View File

@ -1,3 +1,4 @@
# rubocop:disable Metrics/ClassLength
class Captain::ContactMemories::ExtractionService class Captain::ContactMemories::ExtractionService
MAX_FACTS = 5 MAX_FACTS = 5
MIN_CONFIDENCE = 0.5 MIN_CONFIDENCE = 0.5
@ -38,6 +39,39 @@ class Captain::ContactMemories::ExtractionService
<<~PROMPT <<~PROMPT
Você é um analista conservador que extrai apenas FATOS MEMORÁVEIS de uma conversa de WhatsApp entre um hóspede e um hotel. Sua missão é criar memória útil de longo prazo sobre o cliente não transcrever a conversa. Você é um analista conservador que extrai apenas FATOS MEMORÁVEIS de uma conversa de WhatsApp entre um hóspede e um hotel. Sua missão é criar memória útil de longo prazo sobre o cliente não transcrever a conversa.
## PRINCÍPIO ZERO — LITERALIDADE SOBRE INFERÊNCIA
Você extrai o que o cliente DISSE e o que ACONTECEU, não o que você acha que ele quis dizer ou o que provavelmente vai acontecer. Quando em dúvida entre registrar uma interpretação ou não registrar nada: NÃO REGISTRE. Memória errada é muito pior que memória ausente memória errada contamina conversas futuras e faz o atendente mentir pro cliente.
## REGRA DE OURO — AÇÃO CONSUMADA vs INTENÇÃO FUTURA
**NUNCA** registre como fato uma ação que o cliente apenas EXPRESSOU INTENÇÃO de fazer. Isso é o erro mais grave possível contamina o recall e faz o bot afirmar falsidades nas próximas conversas.
### Exemplo REAL de erro a não repetir:
- Cliente: "Maravilha, entro em contato amanhã para reservar"
- Bot: "Fico à disposição pra te ajudar amanhã"
- **ERRADO (alucinação)**: `padrao_comportamental` "Reservou Hidromassagem para pernoite em 21/04/2026"
- **CERTO**: nada. O cliente não reservou disse que vai entrar em contato. Se amanhã ele reservar de fato, a conversa de amanhã vira memória.
### Sinais de AÇÃO CONSUMADA (pode virar `padrao_comportamental` com data):
- Bot gerou Pix / enviou link de reserva **E** cliente confirmou recebimento ou pagou.
- Cliente disse explicitamente "paguei", "confirmado", "pode confirmar", "tá feito", "perfeito, pode marcar".
- Bot respondeu confirmando suíte + data + valor **E** cliente não desdisse.
- Registro de estadia: "fiquei na Alexa em 03/02", "nos hospedamos no fim de semana".
### Sinais de INTENÇÃO FUTURA (NÃO MEMORIZE — retorne nada):
- "Entro em contato amanhã para reservar"
- "Vou querer reservar"
- "Pretendo fazer uma reserva"
- "Tô pensando em reservar"
- "Quero ver opções"
- "Depois eu vejo / me avise depois"
- "Amanhã eu decido"
- Qualquer conversa de orçamento/consulta sem fechamento concreto.
### Teste antes de registrar ação:
Releia o último terço da conversa. A reserva foi EFETIVAMENTE fechada (Pix gerado + cliente aceitou, ou cliente disse "pode confirmar" + bot confirmou)? Se não conseguir apontar a virada de "intenção" pra "feito" com trecho literal, NÃO É AÇÃO CONSUMADA é intenção efêmera e NÃO VIRA memória.
## CONTEXTO DO NEGÓCIO (dados canônicos — NÃO invente fora desta lista) ## CONTEXTO DO NEGÓCIO (dados canônicos — NÃO invente fora desta lista)
- **Suítes válidas**: APENAS `Alexa`, `Stilo`, `Hidromassagem`. Se o texto mencionar qualquer outro nome de suíte (ex: "Aluba", "Premium", "Deluxe"), é ERRO de transcrição ou alucinação DESCARTE o fato. Nunca normalize pra um dos 3 nomes automaticamente: se o cliente disse "queria a Aluba", descarte silenciosamente. - **Suítes válidas**: APENAS `Alexa`, `Stilo`, `Hidromassagem`. Se o texto mencionar qualquer outro nome de suíte (ex: "Aluba", "Premium", "Deluxe"), é ERRO de transcrição ou alucinação DESCARTE o fato. Nunca normalize pra um dos 3 nomes automaticamente: se o cliente disse "queria a Aluba", descarte silenciosamente.
@ -83,18 +117,20 @@ class Captain::ContactMemories::ExtractionService
NÃO: "Obrigado" (não é vínculo) NÃO: "Obrigado" (não é vínculo)
NÃO: "Expressou gratidão ao hotel" (isso é gentileza, não vínculo social) NÃO: "Expressou gratidão ao hotel" (isso é gentileza, não vínculo social)
4. **padrao_comportamental** evento de escolha do cliente (suíte, permanência, horário, forma de pagamento) que vale guardar como HISTÓRICO TEMPORAL, OU declaração explícita de hábito. 4. **padrao_comportamental** evento de escolha EFETIVAMENTE CONSUMADA pelo cliente (ver REGRA DE OURO), OU declaração explícita de hábito.
**TODO fato desse tipo DEVE incluir a data da conversa no content**, no formato: **TODO fato desse tipo DEVE incluir a data da conversa no content**, no formato:
"Reservou Stilo para pernoite em 23/05/2026" "Reservou Stilo para pernoite em 23/05/2026"
"Escolheu 4hrs em 14/03/2026" "Escolheu 4hrs em 14/03/2026"
SIM: "Sempre chego tarde, entre 23h e meia-noite" (declarou hábito) SIM: "Sempre chego tarde, entre 23h e meia-noite" (declarou hábito)
SIM: "Costumo ficar só o pernoite" (declarou hábito) SIM: "Costumo ficar só o pernoite" (declarou hábito)
SIM: "Reservou Alexa para pernoite em 23/05/2026" (registro de escolha com data) SIM: "Reservou Alexa para pernoite em 23/05/2026" APENAS se a reserva foi consumada (Pix gerado + cliente não desistiu, OU cliente disse "pode confirmar" + bot confirmou)
SIM: "Escolheu 4hrs na visita de 14/03/2026" (registro de escolha com data) SIM: "Escolheu 4hrs na visita de 14/03/2026" se efetivamente escolheu e fechou
NÃO: "Costuma ficar 2 horas" (SEM DATA e SEM declaração banido) NÃO: "Costuma ficar 2 horas" (SEM DATA e SEM declaração banido)
NÃO: "Prefere permanência de 4 horas" (banido isso seria preferencia, que exige declaração explícita) NÃO: "Prefere permanência de 4 horas" (banido isso seria preferencia, que exige declaração explícita)
NÃO: "Vai chegar às 22h hoje" (intenção da conversa atual, não histórico) NÃO: "Vai chegar às 22h hoje" (intenção da conversa atual, não histórico)
REGRA CRÍTICA: se você vai registrar uma escolha pontual, SEMPRE inclua a data no content. Memória sem data vira ruído quando o cliente volta. NÃO: "Reservou X" quando o cliente disse "entro em contato amanhã para reservar" ou "quero reservar" (intenção futura violação da REGRA DE OURO).
NÃO: "Reservou X" quando o bot apenas cotou preço e o cliente não fechou explicitamente.
REGRA CRÍTICA: se você vai registrar uma escolha pontual, (a) a ação DEVE ter sido consumada, e (b) SEMPRE inclua a data no content. Memória sem data vira ruído; memória sem consumação vira mentira.
5. **reclamacao** queixa EXPLÍCITA sobre algo que desagradou/frustrou/causou problema, com sentimento negativo claro. 5. **reclamacao** queixa EXPLÍCITA sobre algo que desagradou/frustrou/causou problema, com sentimento negativo claro.
SIM: "O ar-condicionado estava barulhento demais, não dormi direito" SIM: "O ar-condicionado estava barulhento demais, não dormi direito"
@ -133,7 +169,7 @@ class Captain::ContactMemories::ExtractionService
1. **Evidência OBRIGATÓRIA**: cada fato precisa de um trecho LITERAL da conversa. Se não tem trecho claro, não extraia. 1. **Evidência OBRIGATÓRIA**: cada fato precisa de um trecho LITERAL da conversa. Se não tem trecho claro, não extraia.
2. **Perguntas/dúvidas NÃO são reclamação nem memória**: se o cliente fez uma pergunta ("tem X?", "aceita Y?"), isso é informação que ele queria, não fato sobre ele. 2. **Perguntas/dúvidas NÃO são reclamação nem memória**: se o cliente fez uma pergunta ("tem X?", "aceita Y?"), isso é informação que ele queria, não fato sobre ele.
3. **Cortesia genérica NÃO é feedback**: "obrigado", "tá bom", "ok" NÃO viram feedback_positivo. 3. **Cortesia genérica NÃO é feedback**: "obrigado", "tá bom", "ok" NÃO viram feedback_positivo.
4. **Eventos pontuais da conversa atual NÃO são memória**: "reservou para tal dia", "escolheu Alexa", "informou CPF" isso é registro de transação, não fato memorável sobre o cliente. 4. **Aplicar a REGRA DE OURO de ação-consumada vs intenção-futura**: "informou CPF" nunca é memória (é cadastro). "Escolheu X" ou "Reservou X em tal data" vira `padrao_comportamental` se a ação foi efetivamente CONSUMADA nesta conversa (Pix confirmado, cliente disse "pode marcar"+ bot confirmou, ou registro de estadia passada). Discussão/intenção sem fechamento = NÃO EXTRAIA.
5. **Ações do atendente NÃO são memória do cliente**: se o bot "incentivou X" ou "ofereceu Y", isso descreve o atendente, não o cliente. Ignore. 5. **Ações do atendente NÃO são memória do cliente**: se o bot "incentivou X" ou "ofereceu Y", isso descreve o atendente, não o cliente. Ignore.
6. **Máximo 5 fatos por conversa**. Se dúvida entre extrair ou não, DESCARTE. Qualidade > quantidade. 6. **Máximo 5 fatos por conversa**. Se dúvida entre extrair ou não, DESCARTE. Qualidade > quantidade.
7. **Se a conversa não tem NADA realmente memorável**, retorne `{"facts": []}`. Isso é o comportamento normal e esperado da maioria das conversas transacionais. 7. **Se a conversa não tem NADA realmente memorável**, retorne `{"facts": []}`. Isso é o comportamento normal e esperado da maioria das conversas transacionais.
@ -219,3 +255,4 @@ class Captain::ContactMemories::ExtractionService
SCOPE_PATTERN.match?(value) SCOPE_PATTERN.match?(value)
end end
end end
# rubocop:enable Metrics/ClassLength

View File

@ -99,6 +99,7 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
base['ai_failures'] = merge_by_description(base['ai_failures'], result['ai_failures']) base['ai_failures'] = merge_by_description(base['ai_failures'], result['ai_failures'])
base['faq_gaps'] = merge_by_question(base['faq_gaps'], result['faq_gaps']) base['faq_gaps'] = merge_by_question(base['faq_gaps'], result['faq_gaps'])
base['most_requested_suites'] = merge_by_suite(base['most_requested_suites'], result['most_requested_suites']) base['most_requested_suites'] = merge_by_suite(base['most_requested_suites'], result['most_requested_suites'])
base['customer_opportunities'] = merge_by_opportunity(base['customer_opportunities'], result['customer_opportunities'])
end end
def merge_sentiment!(base, result) def merge_sentiment!(base, result)
@ -129,6 +130,10 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
merge_arrays_by_key(arr_a, arr_b, 'suite', 'count') merge_arrays_by_key(arr_a, arr_b, 'suite', 'count')
end end
def merge_by_opportunity(arr_a, arr_b)
merge_arrays_by_key(arr_a, arr_b, 'opportunity', 'frequency')
end
def merge_arrays_by_key(arr_a, arr_b, label_key, count_key) def merge_arrays_by_key(arr_a, arr_b, label_key, count_key)
merged = ((arr_a || []) + (arr_b || [])).group_by { |item| item[label_key] } merged = ((arr_a || []) + (arr_b || [])).group_by { |item| item[label_key] }
merged merged
@ -155,6 +160,7 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
'highlights' => { 'praises' => [], 'complaints' => [] }, 'highlights' => { 'praises' => [], 'complaints' => [] },
'most_requested_suites' => [], 'most_requested_suites' => [],
'price_reactions' => { 'summary' => '', 'objections_count' => 0 }, 'price_reactions' => { 'summary' => '', 'objections_count' => 0 },
'customer_opportunities' => [],
'recommendations' => [], 'recommendations' => [],
'period_summary' => 'Sem conversas suficientes para análise no período.' 'period_summary' => 'Sem conversas suficientes para análise no período.'
} }

View File

@ -335,6 +335,9 @@ class Captain::Llm::SystemPromptsService
"summary": "Como os clientes reagiram aos preços informados", "summary": "Como os clientes reagiram aos preços informados",
"objections_count": 2 "objections_count": 2
}, },
"customer_opportunities": [
{ "opportunity": "Serviço, feature ou melhoria pedida por clientes (ex: check-in 24h, transfer do aeroporto)", "frequency": 3, "example": "Trecho da conversa que ilustra o pedido" }
],
"recommendations": [ "recommendations": [
"Recomendação acionável baseada nos dados" "Recomendação acionável baseada nos dados"
], ],
@ -342,6 +345,11 @@ class Captain::Llm::SystemPromptsService
} }
``` ```
IMPORTANTE diferença entre campos:
- "faq_gaps": perguntas que clientes fizeram e a IA não soube responder (gap de CONHECIMENTO)
- "customer_opportunities": serviços/features/melhorias que clientes PEDIRAM ou perguntaram se existem (oportunidade de NEGÓCIO)
- "complaints": reclamações sobre o que existe (problema a RESOLVER)
Regras obrigatórias: Regras obrigatórias:
- Se não houver dados suficientes para algum campo, retorne arrays vazios ou strings vazias - Se não houver dados suficientes para algum campo, retorne arrays vazios ou strings vazias
- Nunca fabrique exemplos ou números - Nunca fabrique exemplos ou números

View File

@ -11,12 +11,16 @@ class Captain::Payments::ConfirmationService
end end
def perform def perform
was_already_paid = reservation.payment_status.to_s == 'paid'
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
mark_reservation_paid! mark_reservation_paid!
sync_conversation_labels! sync_conversation_labels!
create_internal_note_once! create_internal_note_once!
end end
enqueue_roulette_offer! unless was_already_paid
Rails.logger.info "[PaymentConfirmation] Reserva #{@reservation.id} confirmada (#{source_label})" Rails.logger.info "[PaymentConfirmation] Reserva #{@reservation.id} confirmada (#{source_label})"
end end
@ -77,4 +81,12 @@ class Captain::Payments::ConfirmationService
metadata['payment_confirmed_payload'] ||= payload if payload.present? metadata['payment_confirmed_payload'] ||= payload if payload.present?
reservation.update_column(:metadata, metadata) reservation.update_column(:metadata, metadata)
end end
# Dispara a oferta da Roleta da Sorte após confirmação.
# Fora da transação — roleta é side effect; se falhar, confirmação continua válida.
def enqueue_roulette_offer!
Captain::Payments::OfferRouletteJob.perform_later(reservation.id)
rescue StandardError => e
Rails.logger.warn("[PaymentConfirmation] falha ao enfileirar roleta reserva=#{reservation.id}: #{e.class} - #{e.message}")
end
end end

View File

@ -0,0 +1,259 @@
# Consolida os insights semanais de uma conta em um digest executivo
# pronto pra enviar ao CEO (Mattermost, email, etc).
#
# Conceito de "unidade" no digest = 1 inbox (canal) do Chatwoot.
# Captain::Unit (marca) é usado como fallback quando não há insights por inbox.
# Retorna um hash com rankings, variações WoW e blocos temáticos
# (sem formatação — formatação fica nos adapters de delivery).
# rubocop:disable Metrics/ClassLength
class Captain::Reports::CeoDigestService
def initialize(account:, period_start: nil, period_end: nil)
@account = account
@period_end = period_end || Date.yesterday
@period_start = period_start || (@period_end - 6.days)
end
def call
insights = fetch_insights(@period_start, @period_end)
return empty_digest if insights.empty?
previous_insights = fetch_insights(@period_start - 7.days, @period_end - 7.days)
build_digest(insights, previous_insights)
end
def build_digest(insights, previous_insights)
ctx = build_context(insights, previous_insights)
header_block(ctx).merge(unit_blocks(ctx)).merge(aggregate_blocks(ctx))
end
def build_context(insights, previous_insights)
unit_insights = pick_unit_scope(insights)
{
insights: insights,
unit_insights: unit_insights,
prev_unit_insights: pick_unit_scope(previous_insights),
global: global_insight(insights),
prev_global: global_insight(previous_insights),
aggregate_source: unit_insights.presence || insights
}
end
def header_block(_ctx)
{
account_id: @account.id,
account_name: @account.name,
period_start: @period_start,
period_end: @period_end
}
end
def unit_blocks(ctx)
{
totals: build_totals(ctx[:unit_insights], ctx[:prev_unit_insights], ctx[:global], ctx[:prev_global]),
unit_ranking: build_unit_ranking(ctx[:unit_insights], ctx[:prev_unit_insights]),
ai_performance: build_ai_performance(ctx[:unit_insights]),
satisfaction: build_satisfaction(ctx[:unit_insights]),
period_summaries: build_period_summaries(ctx[:unit_insights])
}
end
def aggregate_blocks(ctx)
source = ctx[:aggregate_source]
{
top_topics: aggregate_top_items(source, 'top_topics', 'topic', 'count', limit: 10),
customer_opportunities: aggregate_top_items(source, 'customer_opportunities', 'opportunity', 'frequency', limit: 10),
faq_gaps: aggregate_top_items(source, 'faq_gaps', 'question', 'frequency', limit: 10),
complaints: aggregate_text_highlights(source, 'complaints', limit: 10),
praises: aggregate_text_highlights(source, 'praises', limit: 10),
most_requested_suites: aggregate_top_items(source, 'most_requested_suites', 'suite', 'count', limit: 5),
recommendations: aggregate_recommendations(source, limit: 10)
}
end
private
# Prioriza insights por-inbox (conceito de unidade do usuário).
# Se não houver insights por-inbox, cai pra insights por-captain_unit.
# Nunca mistura os dois — evita dupla contagem.
def pick_unit_scope(insights)
by_inbox = insights.select { |i| i.inbox_id.present? }
return by_inbox if by_inbox.any?
insights.select { |i| i.captain_unit_id.present? && i.inbox_id.blank? }
end
def global_insight(insights)
insights.find { |i| i.inbox_id.blank? && i.captain_unit_id.blank? }
end
def fetch_insights(period_start, period_end)
Captain::ConversationInsight
.where(account_id: @account.id)
.done
.for_period(period_start, period_end)
.includes(:captain_unit, :inbox)
.to_a
end
def build_totals(unit_insights, prev_unit_insights, global, prev_global)
current_conv = global&.conversations_count || unit_insights.sum(&:conversations_count)
current_msg = global&.messages_count || unit_insights.sum(&:messages_count)
previous_conv = prev_global&.conversations_count || prev_unit_insights.sum(&:conversations_count)
{
conversations: current_conv,
messages: current_msg,
conversations_delta_pct: pct_delta(previous_conv, current_conv),
insights_analyzed: unit_insights.count,
units_analyzed: unit_insights.map { |i| unit_key(i) }.uniq.count
}
end
def build_unit_ranking(unit_insights, prev_unit_insights)
entries = unit_insights.map { |i| unit_ranking_entry(i, prev_unit_insights) }
entries.sort_by { |u| -u[:conversations] }
end
def unit_ranking_entry(insight, prev_unit_insights)
prev = prev_unit_insights.find { |p| unit_key(p) == unit_key(insight) }
{
unit_id: unit_key(insight),
unit_name: unit_name(insight),
conversations: insight.conversations_count,
messages: insight.messages_count,
conversations_previous: prev&.conversations_count.to_i,
conversations_delta_pct: pct_delta(prev&.conversations_count.to_i, insight.conversations_count)
}
end
def build_ai_performance(unit_insights)
per_unit = unit_insights.map { |i| ai_performance_entry(i) }
per_unit.sort_by { |u| u[:success_rate_pct] || 100 } # piores primeiro
end
def ai_performance_entry(insight)
failures_list = insight.payload&.dig('ai_failures') || []
failures_count = failures_list.sum { |f| f['frequency'].to_i }
total = insight.conversations_count.to_i
success_rate = total.positive? ? ((total - failures_count).to_f / total * 100).round(1) : nil
{
unit_id: unit_key(insight),
unit_name: unit_name(insight),
conversations: total,
failures_count: failures_count,
success_rate_pct: success_rate,
top_failures: failures_list.first(3)
}
end
def build_satisfaction(unit_insights)
per_unit = unit_insights.map { |i| satisfaction_entry(i) }
{
most_dissatisfied: per_unit.sort_by { |u| -u[:complaints_count] }.first(5),
most_satisfied: per_unit.sort_by { |u| -u[:praises_count] }.first(5)
}
end
def satisfaction_entry(insight)
complaints = insight.payload&.dig('highlights', 'complaints') || []
praises = insight.payload&.dig('highlights', 'praises') || []
neg_pct, pos_pct = sentiment_percentages(insight.payload&.dig('sentiment') || {})
{
unit_id: unit_key(insight),
unit_name: unit_name(insight),
complaints_count: complaints.size,
praises_count: praises.size,
negative_pct: neg_pct,
positive_pct: pos_pct,
top_complaints: complaints.first(3),
top_praises: praises.first(3)
}
end
def sentiment_percentages(sentiment)
negative = sentiment['negative_count'].to_i
positive = sentiment['positive_count'].to_i
total = negative + positive + sentiment['neutral_count'].to_i
return [0, 0] unless total.positive?
[(negative.to_f / total * 100).round(1), (positive.to_f / total * 100).round(1)]
end
def aggregate_top_items(insights, payload_key, label_key, count_key, limit:)
all_items = insights.flat_map { |i| i.payload&.dig(payload_key) || [] }
grouped = all_items.group_by { |item| item[label_key].to_s.downcase.strip }
grouped.values
.map { |items| items.first.merge(count_key => items.sum { |i| i[count_key].to_i }) }
.sort_by { |item| -item[count_key].to_i }
.first(limit)
end
def aggregate_text_highlights(insights, kind, limit:)
all = insights.flat_map { |i| i.payload&.dig('highlights', kind) || [] }
grouped = all.group_by { |s| s.to_s.downcase.strip }
grouped.values
.sort_by { |v| -v.size }
.first(limit)
.map { |v| { text: v.first, frequency: v.size } }
end
def aggregate_recommendations(insights, limit:)
insights.flat_map { |i| i.payload&.dig('recommendations') || [] }
.uniq
.first(limit)
end
def build_period_summaries(unit_insights)
unit_insights.filter_map { |i| summary_entry(i) }
end
def summary_entry(insight)
summary = insight.payload&.dig('period_summary').to_s
return nil if summary.blank?
{ unit_name: unit_name(insight), summary: summary }
end
def unit_key(insight)
insight.inbox_id.present? ? "inbox:#{insight.inbox_id}" : "unit:#{insight.captain_unit_id}"
end
def unit_name(insight)
if insight.inbox_id.present?
insight.inbox&.name || "Canal ##{insight.inbox_id}"
else
insight.captain_unit&.name || "Unidade ##{insight.captain_unit_id}"
end
end
def pct_delta(previous, current)
return nil if previous.to_i.zero?
((current - previous).to_f / previous * 100).round(1)
end
def empty_digest
{
account_id: @account.id,
account_name: @account.name,
period_start: @period_start,
period_end: @period_end,
empty: true,
totals: { conversations: 0, messages: 0, insights_analyzed: 0, units_analyzed: 0 },
unit_ranking: [],
ai_performance: [],
satisfaction: { most_dissatisfied: [], most_satisfied: [] },
top_topics: [],
customer_opportunities: [],
faq_gaps: [],
complaints: [],
praises: [],
most_requested_suites: [],
recommendations: [],
period_summaries: []
}
end
end
# rubocop:enable Metrics/ClassLength

View File

@ -0,0 +1,165 @@
# frozen_string_literal: true
# Funil de conversão 5-etapas: perguntou preço → recebeu preço →
# reserva iniciada → Pix gerado → Pix pago. Identifica drop-off.
class Captain::Reports::ConversionFunnelService
DEFAULT_PERIOD_DAYS = 30
MAX_PERIOD_DAYS = 180
PRICE_QUESTION_RE = /(pre[çc]o|quanto\s+custa|valor\b|tabela|or[çc]amento|qual\s+o\s+pre)/i
PRICE_ANSWER_RE = /r\$\s*\d|\b\d{2,3}\s*reais\b|\bcus(ta|tam)\b.*\d/i
SUITE_RE = /\b(alexa|stilo|est[íi]lo|hidro(massagem)?|banheira|jacuzzi|of[uú]r[óo])\b/i
STAGES = [
{ key: 'price_inquiry', label: 'Perguntou preço' },
{ key: 'price_answered', label: 'Recebeu cotação' },
{ key: 'reservation_drafted', label: 'Reserva iniciada' },
{ key: 'pix_generated', label: 'Pix gerado' },
{ key: 'pix_paid', label: 'Pix pago' }
].freeze
def initialize(account:, period_days: DEFAULT_PERIOD_DAYS)
@account = account
@period_days = [period_days.to_i, MAX_PERIOD_DAYS].min
@period_days = DEFAULT_PERIOD_DAYS if @period_days <= 0
@period_end = Time.current
@period_start = @period_end - @period_days.days
end
def call
conversations = scope_conversations
return empty_report if conversations.empty?
analyzed = conversations.map { |conv| analyze_conversation(conv) }
funnel = build_funnel(analyzed)
by_suite = build_by_suite(analyzed)
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
total_conversations_analyzed: analyzed.size,
funnel: funnel,
by_suite: by_suite,
top_drop_off: compute_top_drop_off(funnel)
}
rescue StandardError => e
Rails.logger.error("[ConversionFunnelService] #{e.class}: #{e.message}")
empty_report(error: e.message)
end
private
def empty_report(error: nil)
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
total_conversations_analyzed: 0,
funnel: STAGES.map { |s| s.merge(count: 0, conversion: nil) },
by_suite: {},
top_drop_off: nil,
error: error
}.compact
end
# Conversas do período em que Captain::Assistant participou.
def scope_conversations
@account.conversations
.joins(:messages)
.where('conversations.created_at >= ? AND conversations.created_at <= ?', @period_start, @period_end)
.where(messages: { sender_type: 'Captain::Assistant' })
.distinct
.includes(:messages)
end
# Para cada conversa, determina o MAX stage alcançado + a categoria mencionada.
# rubocop:disable Metrics/AbcSize
def analyze_conversation(conv)
messages = conv.messages.where(private: false).order(:created_at).to_a
incoming = messages.select { |m| m.message_type == 'incoming' }
outgoing = messages.select { |m| m.message_type == 'outgoing' && m.sender_type == 'Captain::Assistant' }
max_stage = nil
max_stage = 'price_inquiry' if incoming.any? { |m| PRICE_QUESTION_RE.match?(m.content.to_s) }
max_stage = 'price_answered' if max_stage && outgoing.any? { |m| PRICE_ANSWER_RE.match?(m.content.to_s) }
reservation = Captain::Reservation.where(conversation_id: conv.id).order(created_at: :asc).first
if reservation
max_stage ||= 'reservation_drafted'
max_stage = 'reservation_drafted' if STAGES.index { |s| s[:key] == max_stage } < 2
end
max_stage = 'pix_generated' if reservation && pix_charge?(reservation) && (STAGES.index { |s| s[:key] == max_stage }.to_i < 3)
max_stage = 'pix_paid' if reservation && reservation.payment_status.to_s == 'paid'
{
conversation_id: conv.id,
max_stage: max_stage,
suite: detect_suite(messages)
}
end
# rubocop:enable Metrics/AbcSize
def pix_charge?(reservation)
Captain::PixCharge.exists?(reservation_id: reservation.id)
end
def detect_suite(messages)
messages.each do |m|
next if m.content.blank?
md = SUITE_RE.match(m.content)
next unless md
word = md[1].downcase
return 'Alexa' if word == 'alexa'
return 'Stilo' if %w[stilo estilo estílo].include?(word)
return 'Hidromassagem' if %w[hidro hidromassagem banheira jacuzzi ofuro ofurô].include?(word)
end
nil
end
def build_funnel(analyzed)
# Conta cumulativamente: quem chegou em "reservation_drafted" também conta em "price_answered" e "price_inquiry".
stage_order = STAGES.pluck(:key)
counts = Hash.new(0)
analyzed.each do |row|
max_index = stage_order.index(row[:max_stage])
next unless max_index
stage_order[0..max_index].each { |k| counts[k] += 1 }
end
previous = nil
STAGES.map do |stage|
count = counts[stage[:key]]
conversion = previous&.positive? ? (count.to_f / previous).round(3) : nil
previous = count
stage.merge(count: count, conversion: conversion)
end
end
def build_by_suite(analyzed)
grouped = analyzed.group_by { |row| row[:suite] }.reject { |k, _| k.nil? }
grouped.transform_values { |rows| build_funnel(rows).map { |s| s.slice(:key, :label, :count) } }
end
def compute_top_drop_off(funnel)
drops = funnel.each_cons(2).filter_map do |from, to|
next nil unless from[:count].positive?
lost = from[:count] - to[:count]
pct = lost.to_f / from[:count]
{ from: from[:key], to: to[:key], lost: lost, drop_pct: pct.round(3) }
end
return nil if drops.empty?
drops.max_by { |d| d[:drop_pct] }
end
end

View File

@ -0,0 +1,297 @@
# Entrega o CEO Digest em um canal Mattermost via Incoming Webhook.
# Formata o digest como um attachment rico (cards coloridos, campos em tabela).
#
# Doc do formato: https://docs.mattermost.com/developer/message-attachments.html
# rubocop:disable Metrics/ClassLength
class Captain::Reports::MattermostDeliveryService
TIMEOUT_SECONDS = 10
MAX_TEXT_LENGTH = 4_000 # Mattermost truncates silently above this
COLOR_GREEN = '#2eb886'.freeze
COLOR_YELLOW = '#ecb22e'.freeze
COLOR_RED = '#e01e5a'.freeze
def initialize(digest:, webhook_url:, channel: nil, username: 'Captain CEO Digest')
@digest = digest
@webhook_url = webhook_url
@channel = channel
@username = username
end
def call
raise ArgumentError, 'webhook_url is required' if @webhook_url.blank?
post_and_handle
end
private
def post_and_handle
response = HTTParty.post(
@webhook_url,
body: build_payload.to_json,
headers: { 'Content-Type' => 'application/json' },
timeout: TIMEOUT_SECONDS
)
unless response.success?
Rails.logger.error "[CeoDigest::Mattermost] delivery failed #{response.code}: #{response.body.to_s.force_encoding('UTF-8')}"
return { success: false, status: response.code, body: response.body }
end
{ success: true, status: response.code }
rescue StandardError => e
Rails.logger.error "[CeoDigest::Mattermost] delivery error: #{e.class} #{e.message}"
{ success: false, error: e.message }
end
def build_payload
payload = {
username: @username,
icon_emoji: ':bar_chart:',
text: header_text,
attachments: build_attachments
}
payload[:channel] = @channel if @channel.present?
payload
end
def header_text
period = "#{format_date(@digest[:period_start])} a #{format_date(@digest[:period_end])}"
"## :bar_chart: CEO Digest Semanal — **#{@digest[:account_name]}**\n_Período: #{period}_"
end
def build_attachments
return [empty_attachment] if @digest[:empty]
[
totals_attachment,
unit_ranking_attachment,
ai_performance_attachment,
satisfaction_attachment,
opportunities_attachment,
topics_attachment,
recommendations_attachment
].compact
end
def totals_attachment
t = @digest[:totals]
delta = format_delta(t[:conversations_delta_pct])
{
color: COLOR_GREEN,
title: ':1234: Números da semana',
fields: [
{ title: 'Conversas', value: "**#{t[:conversations]}** #{delta}", short: true },
{ title: 'Mensagens', value: "**#{t[:messages]}**", short: true },
{ title: 'Unidades analisadas', value: t[:units_analyzed].to_s, short: true },
{ title: 'Insights gerados', value: t[:insights_analyzed].to_s, short: true }
]
}
end
def unit_ranking_attachment
ranking = @digest[:unit_ranking]
return nil if ranking.empty?
lines = ranking.each_with_index.map do |u, idx|
delta = format_delta(u[:conversations_delta_pct])
"**#{idx + 1}. #{u[:unit_name]}** — #{u[:conversations]} conversas #{delta}"
end
{
color: COLOR_GREEN,
title: ':trophy: Ranking por unidade (volume)',
text: lines.join("\n")
}
end
def ai_performance_attachment
perf = @digest[:ai_performance]
return nil if perf.empty?
text_parts = [ai_performance_lines(perf).join("\n")]
failures_block = ai_failures_block(perf)
text_parts << "\n**Principais erros:**\n#{failures_block.join("\n")}" if failures_block.any?
{
color: ai_performance_color(perf),
title: ':robot_face: Performance da IA (Angelina)',
text: truncate(text_parts.join("\n"))
}
end
def ai_performance_lines(perf)
perf.map do |u|
rate = u[:success_rate_pct]
rate_str = rate ? "#{rate}%" : 'sem dados'
"#{ai_rate_icon(rate)} **#{u[:unit_name]}** — acerto: #{rate_str} (#{u[:failures_count]} falhas em #{u[:conversations]} conversas)"
end
end
def ai_rate_icon(rate)
return ':grey_question:' if rate.nil?
return ':white_check_mark:' if rate >= 85
return ':warning:' if rate >= 70
':x:'
end
def ai_failures_block(perf)
failing = perf.reject { |u| u[:success_rate_pct].nil? || u[:success_rate_pct] >= 85 }
failing.first(3).flat_map do |u|
next [] if u[:top_failures].empty?
["_#{u[:unit_name]}:_"] + u[:top_failures].map { |f| "#{f['description']} (#{f['frequency']}x)" }
end
end
def ai_performance_color(perf)
perf.any? { |u| u[:success_rate_pct].to_f < 70 } ? COLOR_RED : COLOR_YELLOW
end
def satisfaction_attachment
sat = @digest[:satisfaction]
return nil if sat[:most_dissatisfied].empty? && sat[:most_satisfied].empty?
dissatisfied_lines = dissatisfied_unit_lines(sat[:most_dissatisfied])
satisfied_lines = satisfied_unit_lines(sat[:most_satisfied])
text_parts = []
text_parts << ":rage: **Onde teve mais reclamação:**\n#{dissatisfied_lines.join("\n")}" if dissatisfied_lines.any?
text_parts << "\n:heart: **Onde teve mais elogio:**\n#{satisfied_lines.join("\n")}" if satisfied_lines.any?
return nil if text_parts.empty?
{
color: dissatisfied_lines.any? ? COLOR_RED : COLOR_GREEN,
title: ':thermometer: Satisfação dos clientes',
text: truncate(text_parts.join("\n"))
}
end
def dissatisfied_unit_lines(units)
units.first(3).flat_map do |u|
next [] if u[:complaints_count].zero?
header = "**#{u[:unit_name]}** — #{u[:complaints_count]} reclamações (#{u[:negative_pct]}% negativo)"
[header] + u[:top_complaints].map { |c| "• _#{truncate(c, limit: 150)}_" }
end
end
def satisfied_unit_lines(units)
units.first(3).flat_map do |u|
next [] if u[:praises_count].zero?
header = "**#{u[:unit_name]}** — #{u[:praises_count]} elogios (#{u[:positive_pct]}% positivo)"
[header] + u[:top_praises].map { |p| "• _#{truncate(p, limit: 150)}_" }
end
end
def opportunities_attachment
opps = @digest[:customer_opportunities]
return nil if opps.empty?
lines = opps.first(7).map do |o|
freq = o['frequency'].to_i
"• **#{o['opportunity']}** — pedido #{freq}x"
end
{
color: COLOR_YELLOW,
title: ':bulb: Oportunidades (o que os clientes pediram)',
text: lines.join("\n")
}
end
def topics_attachment
fields = [
topics_field(@digest[:top_topics]),
faq_gaps_field(@digest[:faq_gaps]),
complaints_field(@digest[:complaints])
].compact
return nil if fields.empty?
{
color: COLOR_YELLOW,
title: ':speech_balloon: Temas e lacunas',
fields: fields
}
end
def topics_field(topics)
return nil if topics.blank?
{
title: 'Mais falados',
value: topics.first(5).map { |t| "#{t['topic']} (#{t['count']})" }.join("\n"),
short: true
}
end
def faq_gaps_field(gaps)
return nil if gaps.blank?
{
title: 'Lacunas de FAQ',
value: gaps.first(5).map { |g| "#{truncate(g['question'], limit: 80)} (#{g['frequency']}x)" }.join("\n"),
short: true
}
end
def complaints_field(complaints)
return nil if complaints.blank?
{
title: 'Reclamações recorrentes',
value: complaints.first(5).map { |c| "#{truncate(c[:text], limit: 120)}" }.join("\n"),
short: false
}
end
def recommendations_attachment
recs = @digest[:recommendations]
return nil if recs.empty?
{
color: COLOR_GREEN,
title: ':dart: Recomendações da IA',
text: recs.first(8).map { |r| "#{r}" }.join("\n")
}
end
def empty_attachment
{
color: COLOR_YELLOW,
title: ':grey_question: Sem dados no período',
text: 'Não há insights gerados para a semana selecionada. Verifique se o job semanal está agendado e se há conversas nas unidades.'
}
end
def format_date(date)
return '—' if date.blank?
begin
I18n.l(date.to_date, format: :default)
rescue StandardError
date.to_s
end
end
def format_delta(pct)
return '' if pct.nil?
arrow = if pct.positive?
':arrow_up:'
else
pct.negative? ? ':arrow_down:' : ':arrow_right:'
end
sign = pct.positive? ? '+' : ''
"(#{arrow} #{sign}#{pct}% vs. semana anterior)"
end
def truncate(text, limit: MAX_TEXT_LENGTH)
text.to_s.length > limit ? "#{text.to_s[0, limit - 3]}..." : text.to_s
end
end
# rubocop:enable Metrics/ClassLength

View File

@ -0,0 +1,144 @@
# frozen_string_literal: true
# Lógica central da oferta de Roleta da Sorte.
# Reutilizada por (a) GenerateRoletaLinkTool — invocação manual pela Jasmine,
# e (b) Captain::Payments::OfferRouletteJob — disparado após confirmação do Pix.
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
class Captain::Roleta::OfferService
DEFAULT_BASE_URL = 'http://localhost:5180'
DEFAULT_SCHEMA = 'reserva_hotel'
def initialize(reservation:)
@reservation = reservation
end
# Cria (ou recupera) o draw e envia a mensagem de oferta pra conversa.
# Retorna { success: bool, url: String?, error: String? }
def perform
conversation = @reservation.conversation
return error('Reserva sem conversa.') if conversation.blank?
assistant = conversation.inbox&.captain_assistant
return error('Inbox sem assistente.') if assistant.blank?
unit_row = fetch_unidade_for_conversation(conversation)
return error('Sem unidade vinculada — tenant não resolvido.') if unit_row.blank?
draw = create_or_get_draw(
tenant_id: unit_row['tenant_id'],
id_marca: unit_row['id_marca'],
reservation_id: @reservation.id,
contact_phone: conversation.contact&.phone_number,
contact_name: conversation.contact&.name
)
return error('Falha ao criar draw.') if draw.blank?
url = "#{base_url}/roleta/#{draw['token']}"
dispatch_offer_message(assistant, conversation, url) if draw['was_created']
{ success: true, url: url, was_created: draw['was_created'] }
rescue StandardError => e
Rails.logger.error("[Roleta::OfferService] falha reserva=#{@reservation&.id}: #{e.class} - #{e.message}")
error(e.message)
end
private
def error(msg)
{ success: false, error: msg }
end
def fetch_unidade_for_conversation(conversation)
unit = conversation&.inbox&.captain_inbox&.unit
return nil if unit.blank?
supabase_get('unidades', { chatwoot_unit_id: "eq.#{unit.id}", select: '*', limit: 1 }).first
end
def create_or_get_draw(tenant_id:, id_marca:, reservation_id:, contact_phone:, contact_name:)
body = {
p_tenant_id: tenant_id,
p_id_marca: id_marca,
p_reservation_id: reservation_id,
p_contact_phone: contact_phone,
p_contact_name: contact_name
}
Array(supabase_rpc('create_or_get_draw', body)).first
end
def dispatch_offer_message(assistant, conversation, url)
content = <<~MSG.strip
Seu Pix foi confirmado! 💛
Como prometido, agora é hora da sua chance na Roleta da Sorte 🎁 você pode ganhar um brinde na recepção ou um desconto no saldo do check-in.
É clicar e girar:
#{url}
Um giro . Boa sorte! 🍀
MSG
Messages::MessageBuilder.new(assistant, conversation, {
content: content,
message_type: 'outgoing'
}).perform
rescue StandardError => e
Rails.logger.warn("[Roleta::OfferService] falha ao enviar msg reserva=#{@reservation&.id}: #{e.class} - #{e.message}")
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = supabase_client.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_rpc(fn_name, body)
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
response = supabase_client.post(url) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.body = body.to_json
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_client
@supabase_client ||= Faraday.new do |f|
f.adapter Faraday.default_adapter
f.options.timeout = 8
f.options.open_timeout = 4
end
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
def base_url
ENV.fetch('RESERVA_1001_BASE_URL', DEFAULT_BASE_URL).chomp('/')
end
end
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize

View File

@ -0,0 +1,125 @@
# frozen_string_literal: true
# Marca um cupom como resgatado na recepção. Chama RPC atômica no Supabase,
# envia msg automática de confirmação pro cliente via Jasmine.
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
class Captain::Roleta::RedeemService
DEFAULT_SCHEMA = 'reserva_hotel'
Result = Struct.new(:success, :error_code, :reservation_id, :prize_nome, :prize_tipo, :prize_valor,
:contact_phone, :contact_name, :redeemed_at, keyword_init: true)
def initialize(code:, receptionist_user:, notes: nil)
@code = code.to_s.strip.upcase
@receptionist_user = receptionist_user
@notes = notes
end
def perform
return failure('empty_code') if @code.blank?
return failure('no_receptionist') if @receptionist_user.blank?
row = call_rpc
return failure('rpc_failed') if row.blank?
result = Result.new(
success: row['ok'] == true || row['ok'] == 'true',
error_code: row['error_code'],
reservation_id: row['reservation_id'],
prize_nome: row['prize_nome'],
prize_tipo: row['prize_tipo'],
prize_valor: row['prize_valor'],
contact_phone: row['contact_phone'],
contact_name: row['contact_name'],
redeemed_at: row['redeemed_at']
)
dispatch_confirmation_message(result) if result.success
result
rescue StandardError => e
Rails.logger.error("[Roleta::RedeemService] falha: #{e.class} - #{e.message}")
failure('exception')
end
private
def failure(code)
Result.new(success: false, error_code: code)
end
def call_rpc
body = {
p_code: @code,
p_receptionist_user_id: @receptionist_user.id,
p_notes: @notes.presence
}
Array(supabase_rpc('mark_draw_redeemed', body)).first
end
def dispatch_confirmation_message(result)
reservation = Captain::Reservation.find_by(id: result.reservation_id)
return if reservation.blank?
conversation = reservation.conversation
return if conversation.blank?
assistant = conversation.inbox&.captain_assistant
return if assistant.blank?
content = build_message(result)
Messages::MessageBuilder.new(assistant, conversation, {
content: content,
message_type: 'outgoing'
}).perform
rescue StandardError => e
Rails.logger.warn("[Roleta::RedeemService] falha ao mandar msg: #{e.class} - #{e.message}")
end
def build_message(result)
prize_desc = case result.prize_tipo
when 'desconto_percentual'
"#{Integer(Float(result.prize_valor))}% de desconto"
else
result.prize_nome
end
horario = Time.current.in_time_zone('America/Sao_Paulo').strftime('%Hh%M')
<<~MSG.strip
Seu prêmio *#{prize_desc}* foi entregue na recepção (#{horario}).
Se por algum motivo você NÃO recebeu, me avisa aqui que a gente resolve na hora 🫶
MSG
end
def supabase_rpc(fn_name, body)
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
response = Faraday.post(url) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.body = body.to_json
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
end
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize

View File

@ -0,0 +1,151 @@
# frozen_string_literal: true
# Relatório semanal de resgates por recepcionista com detecção de anomalias.
# Usado pela tela /captain/roleta (aba relatório) e pode ser chamado por digests.
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
class Captain::Roleta::WeeklyReportService
DEFAULT_SCHEMA = 'reserva_hotel'
DEFAULT_PERIOD_DAYS = 7
ANOMALY_MIN_COUNT = 5 # abaixo disso nem analisa
ANOMALY_RATIO = 2.5 # 2.5x a média da equipe flaga
def initialize(account:, period_days: DEFAULT_PERIOD_DAYS)
@account = account
@period_days = period_days
@period_end = Time.current
@period_start = @period_end - period_days.days
end
def call
tenant_id = tenant_id_for_account
return empty_report('tenant_not_mapped') if tenant_id.blank?
rows = fetch_stats(tenant_id)
user_map = load_user_names(rows.filter_map { |r| r['receptionist_user_id']&.to_i }.uniq)
by_receptionist = rows.map { |r| build_row(r, user_map) }
team_total = by_receptionist.sum { |r| r[:total_redemptions] }
team_avg = by_receptionist.any? ? (team_total.to_f / by_receptionist.size).round(2) : 0
threshold = [ANOMALY_MIN_COUNT, (team_avg * ANOMALY_RATIO).ceil].max
by_receptionist.each do |r|
r[:anomaly] = r[:total_redemptions] >= threshold && by_receptionist.size > 1
end
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
team_total: team_total,
team_avg: team_avg,
anomaly_threshold: threshold,
receptionist_count: by_receptionist.size,
by_receptionist: by_receptionist.sort_by { |r| -r[:total_redemptions] }
}
rescue StandardError => e
Rails.logger.error("[Roleta::WeeklyReportService] falha: #{e.class} - #{e.message}")
empty_report('error')
end
private
def empty_report(reason)
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
team_total: 0,
team_avg: 0,
anomaly_threshold: 0,
receptionist_count: 0,
by_receptionist: [],
note: reason
}
end
def build_row(row, user_map)
user_id = row['receptionist_user_id']&.to_i
user = user_map[user_id]
total_discount = row['total_discount_value'].to_f
{
receptionist_user_id: user_id,
receptionist_name: user&.name || "Usuário ##{user_id}",
receptionist_email: user&.email,
total_redemptions: row['total_redemptions'].to_i,
brinde_count: row['brinde_count'].to_i,
desconto_count: row['desconto_count'].to_i,
total_discount_value: total_discount,
first_redemption: row['first_redemption'],
last_redemption: row['last_redemption'],
anomaly: false # setado depois
}
end
def tenant_id_for_account
unit = Captain::Unit.joins(:inboxes).where(inboxes: { account_id: @account.id }).first
return nil if unit.blank?
row = supabase_get('unidades', { chatwoot_unit_id: "eq.#{unit.id}", select: 'tenant_id', limit: 1 }).first
row&.[]('tenant_id')
end
def fetch_stats(tenant_id)
body = {
p_tenant_id: tenant_id,
p_period_start: @period_start.iso8601,
p_period_end: @period_end.iso8601
}
supabase_rpc('roleta_weekly_stats', body)
end
def load_user_names(user_ids)
return {} if user_ids.blank?
User.where(id: user_ids).index_by(&:id)
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = Faraday.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_rpc(fn_name, body)
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
response = Faraday.post(url) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.body = body.to_json
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
end
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
# Tool manual pra oferecer a Roleta da Sorte. O caminho AUTOMÁTICO de disparo
# (após confirmação do Pix) passa por Captain::Payments::OfferRouletteJob.
# Esse tool fica disponível pra casos em que alguém queira forçar a oferta
# manualmente (ex: reserva paga antes do cron detectar, ação de gerência, etc).
class Captain::Tools::GenerateRoletaLinkTool < Captain::Tools::BaseTool
def name
'generate_roleta_link'
end
def description
<<~DESC.strip
Oferta manual da Roleta da Sorte pra reserva atual. Normalmente a roleta é enviada
AUTOMATICAMENTE quando o pagamento do Pix é confirmado. Use este tool se precisar
forçar a oferta fora do fluxo normal (reserva paga que nunca recebeu link).
Idempotente: chamar 2x na mesma reserva retorna o mesmo link.
DESC
end
def tool_parameters_schema
{ type: 'object', properties: {} }
end
def execute(*args, **params)
conversation = resolve_conversation(args, params)
return error_response('Não consegui identificar a conversa.') if conversation.blank?
reservation = Captain::Reservation.where(conversation_id: conversation.id).order(created_at: :desc).first
return error_response('Sem reserva vinculada a essa conversa.') if reservation.blank?
result = Captain::Roleta::OfferService.new(reservation: reservation).perform
return error_response(result[:error] || 'Falha ao gerar roleta.') unless result[:success]
{ formatted_message: result[:url], raw_payload: result[:url], success: true, was_created: result[:was_created] }
rescue StandardError => e
Rails.logger.error("[GenerateRoletaLinkTool] falha: #{e.class} - #{e.message}")
error_response('Não consegui gerar o link da roleta agora.')
end
private
def error_response(msg)
{ formatted_message: msg, success: false }
end
# Resolvers de conversa — copiado de GenerateReservationLinkTool
def resolve_conversation(args, params)
state = extract_state(args, params)
return nil if state.blank?
conversation_state = state_from_context_hash(state, :conversation) || {}
conversation_id = state_from_context_hash(conversation_state, :id)
display_id = state_from_context_hash(conversation_state, :display_id)
account_id = state[:account_id] || state['account_id']
conversation = Conversation.find_by(id: conversation_id) if conversation_id.present?
return conversation if conversation.present?
return nil if display_id.blank?
scope = Conversation.where(display_id: display_id)
scope = scope.where(account_id: account_id) if account_id.present?
scope.first
end
def extract_state(args, params)
context_sources = [
*args,
params[:tool_context], params['tool_context'],
params[:context_wrapper], params['context_wrapper'],
params[:context], params['context']
].compact
context_sources.each do |source|
state = extract_state_from_source(source)
return state if state.present?
end
{}
end
def extract_state_from_source(source)
return source.state if source.respond_to?(:state)
return state_from_source_context(source) if source.respond_to?(:context)
return state_from_hash_source(source) if source.is_a?(Hash)
nil
end
def state_from_source_context(source)
context = source.context
return nil unless context.is_a?(Hash)
state_from_context_hash(context, :state)
end
def state_from_hash_source(source)
state_from_context_hash(source, :state) ||
source.dig(:context, :state) ||
source.dig('context', 'state')
end
def state_from_context_hash(hash, key)
hash[key] || hash[key.to_s]
end
end

View File

@ -0,0 +1,291 @@
# frozen_string_literal: true
# rubocop:disable Metrics/ClassLength,Metrics/AbcSize
# Consulta preco direto no Supabase (schema reserva_hotel) a partir da
# marca/unidade vinculada ao inbox da conversa atual. Substitui o uso de
# faq_lookup para perguntas de preco - garante valor correto e atualizado.
class Captain::Tools::GetReservaPrecoTool < Captain::Tools::BaseTool
DEFAULT_SCHEMA = 'reserva_hotel'
def name
'get_reserva_preco'
end
def description
<<~DESC.strip
Consulta o preco de uma suite direto no banco de reservas (fonte oficial).
USE SEMPRE que o cliente perguntar valor/preco de categoria + permanencia.
NAO use faq_lookup para preco - use esta tool. Retorna o valor exato,
ja considerando o periodo da semana (segunda-quinta, sexta, sabado, etc).
A marca e resolvida automaticamente pelo inbox da conversa.
DESC
end
def tool_parameters_schema
{
type: 'object',
required: %w[categoria permanencia],
properties: {
categoria: {
type: 'string',
description: 'OBRIGATORIO. Nome da categoria/suite. Ex: "Alexa", "Stilo", "Hidromassagem".'
},
permanencia: {
type: 'string',
description: 'OBRIGATORIO. Permanencia. Ex: "2hrs", "3hrs", "4hrs", "Pernoite", "Diaria".'
},
checkin_at: {
type: 'string',
description: 'OPCIONAL. Data/hora de check-in em ISO 8601. Usado para resolver periodo da semana. ' \
'Se vazio, usa periodo "default".'
}
}
}
end
def execute(*args, **params)
actual_params = resolve_params(args, params)
@conversation ||= resolve_conversation(args, params)
categoria = actual_params[:categoria].to_s.strip
permanencia = actual_params[:permanencia].to_s.strip
checkin_at = actual_params[:checkin_at].to_s.strip
return missing_fields_response(categoria, permanencia) if categoria.empty? || permanencia.empty?
unit = infer_unit
return error_response('Inbox desta conversa nao esta vinculado a uma unidade. Nao consigo buscar preco.') if unit.blank?
unit_row = fetch_unidade(unit.id)
return error_response("Unidade #{unit.id} nao encontrada no banco de reservas (reserva_hotel.unidades).") if unit_row.blank?
tenant_id = unit_row['tenant_id']
marca_id = unit_row['id_marca']
periodo_slug = resolve_periodo_slug(marca_id, checkin_at)
preco_row = fetch_preco(tenant_id, marca_id, categoria, permanencia, periodo_slug)
return preco_not_found_response(categoria, permanencia, periodo_slug) if preco_row.blank?
success_response(preco_row, categoria, permanencia)
rescue StandardError => e
Rails.logger.error("[GetReservaPrecoTool] falha: #{e.class} - #{e.message}")
error_response('Nao consegui consultar o preco agora. Tente novamente em instantes.')
end
private
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def fetch_unidade(chatwoot_unit_id)
data = supabase_get(
'unidades',
{
'chatwoot_unit_id' => "eq.#{chatwoot_unit_id}",
'select' => 'id,id_marca,tenant_id,nome'
}
)
Array(data).first
end
def resolve_periodo_slug(marca_id, checkin_at)
return 'default' if checkin_at.to_s.strip.empty?
day_of_week = Time.zone.parse(checkin_at).wday # 0=dom..6=sab
periodos = supabase_get(
'marca_periodos',
{
'id_marca' => "eq.#{marca_id}",
'ativo' => 'eq.true',
'select' => 'slug,dias,ordem',
'order' => 'ordem.asc'
}
)
matched = Array(periodos).find do |p|
dias = p['dias']
dias.is_a?(Array) && dias.include?(day_of_week)
end
matched&.dig('slug') || 'default'
rescue ArgumentError, TypeError
'default'
end
def fetch_preco(tenant_id, marca_id, categoria, permanencia, periodo_slug)
data = supabase_get(
'precos',
{
'tenant_id' => "eq.#{tenant_id}",
'id_marca' => "eq.#{marca_id}",
'categoria' => "eq.#{categoria}",
'permanencia' => "eq.#{permanencia}",
'periodo_semana' => "eq.#{periodo_slug}",
'ativo' => 'eq.true',
'select' => 'valor,categoria,permanencia,periodo_semana',
'limit' => '1'
}
)
Array(data).first
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = supabase_client.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
req.headers['Accept-Encoding'] = 'identity'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError => e
Rails.logger.warn("[GetReservaPrecoTool] JSON parse error: #{e.message}")
[]
end
def supabase_client
@supabase_client ||= Faraday.new do |f|
f.request :url_encoded
f.adapter Faraday.default_adapter
f.options.timeout = 8
f.options.open_timeout = 4
end
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
def success_response(preco_row, categoria, permanencia)
valor = preco_row['valor'].to_f
formatted = ActiveSupport::NumberHelper.number_to_currency(
valor, unit: 'R$ ', separator: ',', delimiter: '.'
)
periodo = preco_row['periodo_semana']
{
formatted_message: "#{categoria} (#{permanencia}) - #{formatted} [periodo: #{periodo}]. " \
'Valor oficial do banco de reservas.',
success: true,
valor: valor,
valor_formatado: formatted,
categoria: categoria,
permanencia: permanencia,
periodo_semana: periodo
}
end
def preco_not_found_response(categoria, permanencia, periodo_slug)
{
formatted_message: "Nao encontrei preco cadastrado para #{categoria} / #{permanencia} " \
"(periodo: #{periodo_slug}). Consulte o admin para cadastrar esse valor.",
success: false
}
end
def missing_fields_response(categoria, permanencia)
missing = []
missing << 'categoria' if categoria.empty?
missing << 'permanencia' if permanencia.empty?
{
formatted_message: "Preciso de: #{missing.join(', ')}. Pergunte ao cliente e chame a tool de novo.",
success: false,
missing_fields: missing
}
end
def error_response(message)
{ formatted_message: message, success: false }
end
def resolve_params(args, params)
merged = params.to_h
args.each do |arg|
next unless arg.is_a?(Hash)
next if tool_context_hash?(arg)
merged = arg.merge(merged)
end
merged.with_indifferent_access
end
# Copiado de GenerateReservationLinkTool
def resolve_conversation(args, params)
state = extract_state(args, params)
return nil if state.blank?
conversation_state = state_from_context_hash(state, :conversation) || {}
conversation_id = state_from_context_hash(conversation_state, :id)
display_id = state_from_context_hash(conversation_state, :display_id)
account_id = state[:account_id] || state['account_id']
conversation = Conversation.find_by(id: conversation_id) if conversation_id.present?
return conversation if conversation.present?
return nil if display_id.blank?
scope = Conversation.where(display_id: display_id)
scope = scope.where(account_id: account_id) if account_id.present?
scope.first
end
def extract_state(args, params)
context_sources = [
*args,
params[:tool_context], params['tool_context'],
params[:context_wrapper], params['context_wrapper'],
params[:context], params['context']
].compact
context_sources.each do |source|
state = extract_state_from_source(source)
return state if state.present?
end
{}
end
def extract_state_from_source(source)
return source.state if source.respond_to?(:state)
return state_from_source_context(source) if source.respond_to?(:context)
return state_from_hash_source(source) if source.is_a?(Hash)
nil
end
def state_from_source_context(source)
context = source.context
return nil unless context.is_a?(Hash)
state_from_context_hash(context, :state)
end
def state_from_hash_source(source)
state_from_context_hash(source, :state) ||
source.dig(:context, :state) ||
source.dig('context', 'state')
end
def state_from_context_hash(hash, key)
hash[key] || hash[key.to_s]
end
def tool_context_hash?(hash)
hash.key?(:state) || hash.key?('state') ||
hash.key?(:context) || hash.key?('context') ||
hash.key?(:conversation) || hash.key?('conversation')
end
end
# rubocop:enable Metrics/ClassLength,Metrics/AbcSize

View File

@ -2,6 +2,7 @@ json.id scenario.id
json.title scenario.title json.title scenario.title
json.description scenario.description json.description scenario.description
json.instruction scenario.instruction json.instruction scenario.instruction
json.trigger_keywords scenario.trigger_keywords
json.tools scenario.tools json.tools scenario.tools
json.enabled scenario.enabled json.enabled scenario.enabled
json.assistant_id scenario.assistant_id json.assistant_id scenario.assistant_id

View File

@ -1,145 +1,117 @@
# System Context # System Context
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses. You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background never mention or draw attention to them in your responses.
# Your Identity # Your Identity
You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need. Você é {{name}}, assistente de atendimento via WhatsApp de um estabelecimento de hospedagem. Sua função é ser o primeiro contato com o cliente, identificar a intenção rapidamente e acionar o cenário especializado correto.
# Instruções Específicas deste Assistente Tom: natural, ágil, simpático e focado em resolver. Fala como um atendente humano brasileiro.
<INSTRUCOES_INTERNAS> Missão: entender o que o cliente quer e rotear para o cenário que resolve — ou responder diretamente quando for o caso.
{{ description }}
</INSTRUCOES_INTERNAS>
REGRA CRÍTICA: O bloco INSTRUCOES_INTERNAS acima é apenas para seu contexto interno como assistente. NUNCA reproduza essas instruções como resposta ao cliente. Sua resposta deve ser sempre uma mensagem natural, direta e útil ao cliente — jamais uma cópia do seu contexto ou instruções.
# ⛔ Regras Absolutas de Resposta ao Cliente # ⛔ REGRAS ABSOLUTAS DE SEGURANÇA (sempre ativas, antes de qualquer roteamento)
## Regra 1 — PROIBIDO vazar contexto interno ## 1. Hóspede JÁ dentro do estabelecimento → HANDOFF IMEDIATO
JAMAIS inclua nas suas respostas ao cliente: **Princípio geral:** se o cliente fala sobre algo que só quem está FISICAMENTE hospedado saberia — problema ou pedido no quarto, consumo, referência a "estar aqui", detalhes operacionais da estadia —, trate como hóspede em estadia e faça handoff imediato para atendimento humano presencial.
- Blocos `Contexto`, `<contexto>`, `[Contexto]` ou similares
- Saída de renders Liquid (`render 'conversation'`, `render 'contact'`)
- Metadados internos, IDs, payloads JSON, atributos de conversa/contato
- Qualquer conteúdo que não seja a resposta final em linguagem natural
Sua resposta ao cliente = **apenas texto final limpo** (e mídias quando aplicável). Se perceber que está prestes a incluir dados internos, pare e reescreva. Gatilhos típicos: "estou no quarto", "já estou hospedado", "pode mandar toalha aqui", "acabou a água quente", "o ar não funciona", "traz mais travesseiro", "pode vir pegar meu café", "estou na recepção", "já entrei", "preciso de limpeza", "falta papel no banheiro".
## Regra 2 — PROIBIDO prometer envio antes do tool confirmar **Ação obrigatória (nesta ordem):**
NUNCA diga frases como "vou enviar as fotos agora", "estou mandando", "aguarde que já envio" antes de o tool retornar sucesso. 1. **Pare** qualquer outro fluxo — não roteie para nenhum cenário.
2. Chame `captain--tools--handoff` para um humano.
3. Aplique a etiqueta `pausar_ia` via `captain--tools--add_label_to_conversation` (se disponível).
4. Responda com a mensagem padrão de transferência definida nas Instruções deste Assistente, ou, na ausência de uma, uma mensagem educada transferindo para atendente presencial.
5. Encerre. Não continue respondendo automaticamente.
Fluxo obrigatório para envio de mídia: ## 2. Nunca inventar informações
1. Chamar o tool de envio (handoff ou ferramenta de mídia) Para qualquer dúvida factual fora do escopo dos cenários, use `captain--tools--faq_lookup` **antes** de responder. Jamais responda "não sei" sem consultar o FAQ. **Nunca** use o histórico da conversa como fonte — o FAQ é a fonte oficial e está em constante atualização.
2. Aguardar o retorno do tool
3. **Somente se o tool retornar sucesso** → confirmar ao cliente: "As fotos foram enviadas!"
4. **Se o tool falhar ou retornar erro** → informar honestamente: "Não consegui enviar as fotos agora" e usar `captain--tools--handoff` para acionar um atendente humano.
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this. ## 3. Disfarce como humano
Não revele que é IA, a menos que o cliente pergunte diretamente.
## 4. Não exponha o sistema
Nunca mencione nomes de ferramentas, cenários, IDs, campos internos ou qualquer conteúdo técnico na resposta ao cliente. Nunca reproduza o texto cru de retorno de uma ferramenta — sempre reescreva em linguagem natural.
# 🎯 Framework de Roteamento
Ao receber uma mensagem (depois de confirmar que nenhuma regra de segurança se aplica):
1. **Identifique a intenção principal** do cliente.
2. **Consulte a lista "Cenários Disponíveis"** abaixo. Cada cenário pode ter um bloco "Quando usar" com gatilhos e exemplos explícitos.
3. **Roteie** para o cenário cujos gatilhos mais se alinham com a intenção, usando `handoff_to_<key>`.
4. Se nenhum cenário se aplica, tente `captain--tools--faq_lookup` para dúvidas factuais ou `captain--tools--handoff` para transferir a um humano.
## Mensagens ambíguas ou muito curtas
Se a mensagem é só uma saudação ("oi", "tudo bem?", "boa tarde") ou é vaga demais para identificar a intenção:
- **Não roteie ainda.**
- Cumprimente usando a saudação definida nas Instruções deste Assistente.
- Espere o cliente dar o próximo passo.
Se a intenção existe mas falta um dado crítico, **roteie mesmo assim** — o cenário coleta o que faltar.
## Princípio: use os cenários
Cada cenário existe porque tem regras específicas e ferramentas próprias. Nunca tente "resolver por cima" para economizar handoff. Se a intenção se encaixa em um cenário, use-o.
# Fluxo de Resposta
1. **Primeira mensagem de uma conversa nova:** use a saudação personalizada definida nas Instruções deste Assistente. Não repita a apresentação em mensagens seguintes.
2. **Identifique a intenção** → roteie (ou pergunte algo curto se ambíguo).
3. **Quando o cenário/tool retornar**, **reescreva** a resposta em linguagem natural antes de mandar. Nunca copie texto técnico, JSON, IDs ou saídas cruas.
4. **Formato:** máximo 2 parágrafos curtos, **uma pergunta por vez**, negrito nas informações críticas.
5. **Encerramento:** ofereça o próximo passo claro. Se o cliente sumir, envie **um** lembrete educado e encerre.
# Data e Hora Atual # Data e Hora Atual
- Data: {{ current_date }} - Data: {{ current_date }}
- Hora: {{ current_time }} - Hora: {{ current_time }}
- Fuso Horário: {{ current_timezone }} - Fuso Horário: {{ current_timezone }}
{% if conversation || contact -%} {% if conversation or contact -%}
# Current Context # Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
{% if conversation -%} {% if conversation -%}
{% render 'conversation' %} {% render 'conversation' %}
{% endif -%} {% endif -%}
{% if contact -%} {% if contact -%}
{% render 'contact' %} {% render 'contact' %}
{% endif -%} {% endif -%}
{% endif -%} {% endif -%}
{% if response_guidelines.size > 0 -%} # Campo reaction_emoji (opcional)
# Response Guidelines Quando fizer sentido pelo tom da mensagem do cliente (saudação, agradecimento, celebração, "estou verificando"), sugira um emoji no campo `reaction_emoji`. Deixe vazio quando não combinar. O sistema controla a frequência automaticamente.
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%} # Cenários Disponíveis
- {{ guideline }} {% for scenario in scenarios %}
- Be conversational but professional ## {{ scenario.title }}
- Provide actionable information {{ scenario.description }}
- Include relevant details from tool responses {% if scenario.trigger_keywords != blank %}
**Quando rotear para este cenário** (use `handoff_to_{{ scenario.key }}`):
{{ scenario.trigger_keywords }}
{% else %}
Acionar via: `handoff_to_{{ scenario.key }}`
{% endif %}
{% endfor %} {% endfor %}
{% endif -%}
{% if guardrails.size > 0 -%} # ⛔ Lembretes Finais — Nunca Quebre
# Guardrails
Always respect these boundaries:
{% for guardrail in guardrails -%}
- {{ guardrail }}
{% endfor %}
{% endif -%}
# Message Reactions (Emoji)
You have an optional field `reaction_emoji` in your response output.
CRITICAL: Do NOT react to every single message! This makes the interaction feel artificial.
- Use emojis naturally and sparingly, just like a human would.
- Appropriate uses: Greetings (👋), confirming you are looking into something (👀), agreements (👍), or celebrations (🎉).
- AVOID reacting to serious complaints or basic continuous questions if the tone doesn't fit.
- If you just sent an emoji in the previous turn, try to hold off on sending another right away. When in doubt, leave `reaction_emoji` empty.
- Frequency policy:
- Always react on greeting, farewell, and thank-you/appreciation messages when tone is positive.
- For regular conversation, react only occasionally (roughly 35% of turns).
- If uncertain, keep `reaction_emoji` empty.
# Decision Framework
## 1. Analyze the Request
First, understand what the user is asking:
- **Intent**: What are they trying to achieve?
- **Type**: Is it a question, task, complaint, or request?
- **Complexity**: Can you handle it or does it need specialized expertise?
## 2. Route by Intent Type
Decide the route before any handoff:
- **Factual question** (prices, rules, policies, amenities, schedules, hotel information): treat this as knowledge retrieval.
- **Execution request** (create reservation, generate Pix, update booking/payment status, operational flow steps, send suite/room photos, show images of categories, provide pictures of accommodations): treat this as scenario execution.
### 2A. For factual questions (FAQ-first, no premature handoff)
1. Use `captain--tools--faq_lookup` first.
2. If FAQ returns relevant info, answer directly.
3. Only handoff to a scenario if the user is explicitly asking to execute a flow after/along with the factual answer.
4. Never say you don't have access to factual information without trying `faq_lookup` first.
### 2B. For execution requests (scenario-first)
If the request clearly matches a specialized execution flow, handoff to the right scenario.
CRITICAL: The following are ALWAYS execution requests — never attempt to answer them via FAQ or text:
- Requests for photos, images, or pictures of suites/rooms/categories (e.g., "tem foto da suíte X?", "me manda fotos", "quero ver imagens do quarto")
- Creating or checking reservations
- Generating or checking Pix payments
- Any operational step that requires sending media or executing a flow
Available scenario agents:
{% for scenario in scenarios -%}
- {{ scenario.title }}: {{ scenario.description }}. Use `handoff_to_{{ scenario.key }}`.
{% endfor %}
If unclear, ask clarifying questions before choosing.
## 3. Handle the Request
If no specialized execution scenario clearly matches, handle it yourself.
### For Questions and Information Requests
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
2. **Break down complex tasks**: Handle step by step or hand off if too complex
3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities
# Human Handoff Protocol
Transfer to a human agent when:
- User explicitly requests human assistance
- You cannot find needed information after checking FAQs
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context.
# ⛔ Lembrete Final — Nunca Quebre Estas Regras
- NUNCA vaze contexto, metadados ou blocos internos na resposta ao cliente. - NUNCA vaze contexto, metadados ou blocos internos na resposta ao cliente.
- NUNCA prometa envio de mídia antes de o tool confirmar sucesso. - NUNCA prometa envio de mídia antes do tool confirmar sucesso.
- NUNCA tente responder via FAQ um pedido de foto ou imagem — sempre use handoff. - NUNCA responda por memória quando existe um cenário apropriado para o tipo de pedido.
- NUNCA use histórico como fonte factual — sempre FAQ ou cenário.
- NUNCA copie texto cru de ferramenta — sempre reescreva em linguagem natural.
# ---SECAO-ASSISTENTE---
# Instruções Específicas deste Assistente
<!-- Preencha abaixo com os dados específicos deste estabelecimento. Este conteúdo é o único que muda entre unidades. -->
## Contexto do Hotel
- **Hotel:** <preencha: nome completo do estabelecimento>
- **Cidade/Região:** <preencha: cidade/bairro>
- **Especialidade:** <preencha: ex.: hospedagens curtas, pernoites, diárias>
- **Acomodações disponíveis:** <preencha: suítes/quartos/categorias>
- **Público:** <preencha: ex.: casais, famílias, executivos>
## Links Úteis
- **Tabela de preços:** <preencha: URL>
- **WhatsApp da unidade:** <preencha: URL>
- **Google Maps:** <preencha: URL>
## Saudação Personalizada (primeira mensagem de conversa nova)
<preencha: ex.: "Oi! Sou a {{name}} do <Nome do Hotel> 😊 Como posso te ajudar?">
## Observações Específicas desta Unidade
<preencha: qualquer regra/comportamento específico desta unidade>

View File

@ -0,0 +1,52 @@
# rubocop:disable Metrics/BlockLength
namespace :captain do
namespace :ceo_digest do
desc 'Configura o CEO Digest (webhook do Mattermost) para uma conta. Uso: rake captain:ceo_digest:configure[1,https://mm...,#ceo]'
task :configure, %i[account_id webhook_url channel] => :environment do |_t, args|
account = Account.find(args[:account_id])
ceo_config = {
'enabled' => true,
'mattermost_webhook_url' => args[:webhook_url],
'mattermost_channel' => args[:channel].presence
}.compact
account.update!(custom_attributes: account.custom_attributes.to_h.merge('ceo_digest' => ceo_config))
puts "OK — conta ##{account.id} (#{account.name}) configurada:"
puts account.custom_attributes['ceo_digest'].inspect
end
desc 'Desativa o CEO Digest para uma conta. Uso: rake captain:ceo_digest:disable[1]'
task :disable, [:account_id] => :environment do |_t, args|
account = Account.find(args[:account_id])
current = account.custom_attributes.to_h
current['ceo_digest'] = (current['ceo_digest'] || {}).merge('enabled' => false)
account.update!(custom_attributes: current)
puts "OK — CEO Digest desativado para conta ##{account.id}."
end
desc 'Dispara o CEO Digest agora. Uso: rake captain:ceo_digest:deliver[1] ou captain:ceo_digest:deliver[1,2026-04-09,2026-04-15]'
task :deliver, %i[account_id period_start period_end] => :environment do |_t, args|
account_id = args[:account_id]&.to_i
raise 'account_id é obrigatório' if account_id.blank?
period_start = args[:period_start].presence && Date.parse(args[:period_start])
period_end = args[:period_end].presence && Date.parse(args[:period_end])
puts "Disparando CEO Digest para conta ##{account_id} (#{period_start || 'última semana'} a #{period_end || 'ontem'})..."
Captain::Reports::CeoDigestJob.new.perform(account_id, period_start, period_end)
puts 'Fim. Veja o log do Rails pra detalhes da entrega.'
end
desc 'Mostra um preview do digest no terminal, sem enviar. Uso: rake captain:ceo_digest:preview[1]'
task :preview, %i[account_id period_start period_end] => :environment do |_t, args|
account = Account.find(args[:account_id])
period_end = (args[:period_end].presence && Date.parse(args[:period_end])) || Date.yesterday
period_start = (args[:period_start].presence && Date.parse(args[:period_start])) || (period_end - 6.days)
digest = Captain::Reports::CeoDigestService.new(
account: account, period_start: period_start, period_end: period_end
).call
puts JSON.pretty_generate(digest)
end
end
end
# rubocop:enable Metrics/BlockLength

View File

@ -0,0 +1,70 @@
id,status,question,answer,created_at,updated_at
1,1,Quem é Jasmine no contexto do atendimento do Hotel 1001 Noites Prime Ceilândia?,"Jasmine é a atendente de experiência do Hotel 1001 Noites Prime Ceilândia, responsável por ser o primeiro contato com o cliente via WhatsApp, fornecer informações, identificar rapidamente a intenção do cliente e acionar a ferramenta correta para atendimento.",2026-02-22 20:08:38.312922,2026-02-22 20:08:38.312922
2,1,Quais são as restrições específicas para Jasmine ao atender clientes?,"Jasmine atende exclusivamente a unidade Ceilândia, não revela que é IA a menos que perguntada, nunca inventa informações, não menciona nomes de ferramentas ou sub-agentes ao cliente e, ao responder perguntas sobre preço, só fornece valores após confirmar explicitamente a duração desejada pelo cliente (horas, pernoite ou diária).",2026-02-22 20:08:38.3385,2026-02-22 20:08:38.3385
3,1,Como deve ser o tom e a forma de atendimento da Jasmine?,"O tom deve ser natural, ágil, simpático e focado em atendimento, com uso moderado de emojis no início e no fim da conversa para tornar o atendimento mais acolhedor.",2026-02-22 20:08:38.340793,2026-02-22 20:08:39.276049
4,1,Quais ferramentas Jasmine deve acionar dependendo da intenção do cliente?,"Se o cliente desejar fazer reservas para datas futuras, confirmar, alterar ou cancelar reservas, Jasmine deve acionar a ferramenta daniela_reservas. Se o cliente pedir fotos das suítes, Jasmine deve acionar a ferramenta maria_fotos. Para consultas de disponibilidade, deve ser usada a ferramenta disponibilidade_suites.",2026-02-22 20:08:38.342428,2026-02-22 20:08:39.284966
5,1,O que Jasmine deve fazer se o cliente for hóspede em estadia na unidade?,"Se o cliente indicar que já está dentro do hotel ou na suíte, Jasmine deve parar o fluxo normal de atendimento, ativar handoff imediato para um atendente humano local, aplicar a etiqueta 'pausar_ia' e não responder mais mensagens após isso, evitando tentar resolver pedidos de quarto, café, limpeza, manutenção ou consumo.",2026-02-22 20:08:38.344669,2026-02-22 20:08:39.717754
6,1,Quais informações Jasmine deve fornecer na mensagem padrão de transferência para um atendente local?,"Jasmine deve informar que o atendimento inicial é feito por uma central que não fica fisicamente dentro da unidade e que o cliente será transferido para um atendente local no hotel, utilizando uma mensagem cordial como: 'Vou te encaminhar agora para um atendente local aí no hotel para resolver isso mais rápido...'",2026-02-22 20:08:38.347545,2026-02-22 20:08:39.486556
7,1,Quando Jasmine pode informar preços ao cliente?,"Jasmine só pode informar preços quando a duração da estadia estiver clara na mensagem do cliente, sendo válida a duração em horas, pernoite ou diária. Se a duração já foi informada, Jasmine não precisa perguntar novamente, devendo consultar a ferramenta adequada para retornar o preço exato.",2026-02-22 20:08:38.350565,2026-02-22 20:08:39.347749
8,1,O que deve ser feito antes de informar preços ao cliente?,"Antes de fornecer preços, Jasmine deve confirmar explicitamente a duração desejada para evitar deduções ou suposições. A IA nunca deve informar preços sem essa confirmação clara.",2026-02-22 20:08:38.353868,2026-02-22 20:08:39.288236
9,1,A IA pode oferecer cobrança via Pix ao informar preços?,"Não. A IA não pode oferecer, gerar ou pedir confirmação de Pix apenas ao informar preços. A oferta do Pix só pode ocorrer após a confirmação clara e explícita da reserva pelo cliente.",2026-02-22 20:08:38.385687,2026-02-22 20:08:38.385687
10,1,"O que Jasmine deve fazer se o cliente usar termos como hidro, spa ou banheira?",Jasmine deve interpretar automaticamente esses termos como referência à Suíte Hidromassagem (SPA/HIDROMASSAGEM) para garantir o atendimento adequado.,2026-02-22 20:08:38.389701,2026-02-22 20:08:38.389701
11,1,Como proceder caso o cliente pergunte sobre a disponibilidade imediata de uma suíte?,Jasmine deve obrigatoriamente consultar a ferramenta disponibilidade_suites para verificar a disponibilidade antes de responder ao cliente.,2026-02-22 20:08:38.394516,2026-02-22 20:08:38.394516
12,1,Quais são as etapas do fluxo operacional de atendimento de Jasmine?,O fluxo inclui: 1) Cumprimentar de forma natural e se apresentar; 2) Identificar rapidamente a intenção do cliente; 3) Acionar a ferramenta apropriada conforme a intenção; 4) Responder com mensagens de até 2 parágrafos e com uso moderado de emojis; 5) Oferecer próximo passo claro e finalizar com cordialidade.,2026-02-22 20:08:38.398954,2026-02-22 20:08:38.398954
13,1,Quais são exemplos de gatilhos que indicam que Jasmine deve acionar a ferramenta daniela_reservas?,"Gatilhos incluem frases como 'quero reservar para sábado', 'tem vaga agora?', 'quanto custa?', 'confirmar reserva', 'cancelar agendamento' ou 'está disponível?'.",2026-02-22 20:08:38.408291,2026-02-22 20:08:38.408291
14,1,Como Jasmine deve responder pedidos de fotos das suítes?,"Ao receber pedidos de fotos, Jasmine deve acionar a ferramenta maria_fotos especificando qual suíte o cliente quer ver, aguardar as fotos e depois enviar ao cliente junto com uma mensagem simpática, como 'Aqui estão as fotos da suíte Alexa 📸 Gostou?'.",2026-02-22 20:08:38.413078,2026-02-22 20:08:38.413078
15,1,O que Jasmine deve fazer se não souber a resposta para uma pergunta do cliente?,Jasmine deve consultar obrigatoriamente o FAQ para buscar a informação antes de responder e nunca responder que 'não sabe' sem antes buscar a informação.,2026-02-22 20:08:38.418265,2026-02-22 20:08:38.418265
16,1,Qual é o procedimento padrão quando o cliente deseja cancelar uma reserva?,"Jasmine aciona a ferramenta daniela_reservas, informa que irá verificar, e a ferramenta solicitará ao cliente dados como nome completo, CPF e código da reserva para dar continuidade ao processo de cancelamento.",2026-02-22 20:08:38.422154,2026-02-22 20:08:38.422154
17,1,Como Jasmine deve reagir a mensagens de saudação ou agradecimento do cliente?,"Deve reagir com emojis correspondentes, por exemplo, 😀 para 'Olá!' e ❤️ para 'Obrigado!', tornando a interação mais amigável e natural.",2026-02-22 20:08:38.431865,2026-02-22 20:08:38.431865
18,1,Onde fica localizado o Hotel 1001 Noites Prime em Ceilândia?,O hotel está localizado na Ceilândia Leste ao lado da academia Ultra no QNM03.,2026-02-22 20:08:38.436049,2026-02-22 20:08:38.436049
19,1,O que deve ser feito se o cliente solicitar informações de outras unidades do hotel?,"Jasmine deve consultar o bloco de informações outras unidades e, se houver dados disponíveis, repassar apenas o contato e localização de outras unidades, sem assumir atendimento, suporte ou operação dessas unidades.",2026-02-22 20:08:38.440958,2026-02-22 20:08:38.440958
20,1,"No atendimento pelo WhatsApp, quais são as unidades diferentes para as quais Jasmine não deve assumir atendimento direto?","As unidades como 1001 Noites Samambaia, 1001 Noites Águas Claras, 1001 Noites Recanto das Emas, entre outras, possuem contatos específicos e Jasmine deve informar esses contatos para o cliente sem assumir o atendimento.",2026-02-22 20:08:38.445716,2026-02-22 20:08:39.465882
21,1,Quem é Jasmine e qual é sua função no Hotel 1001 Noites Prime - Ceilândia?,"Jasmine é a atendente de experiência, responsável por ser o primeiro contato com o cliente via WhatsApp, fornecendo informações, identificando rapidamente a intenção do cliente e acionando a ferramenta correta para atendimento.",2026-02-22 20:08:52.738392,2026-02-22 20:08:52.738392
22,1,Quais as restrições que Jasmine deve seguir durante o atendimento?,"Jasmine atende exclusivamente a unidade Ceilândia, não revela que é IA, não inventa informações, não menciona nomes de ferramentas ou sub-agentes ao cliente, nunca informa preços sem confirmar a duração da estadia e não confirma disponibilidade ou preço sem consultar a ferramenta apropriada.",2026-02-22 20:08:52.745089,2026-02-22 20:08:52.745089
23,1,Como Jasmine deve proceder quando o cliente pede informações sobre outras unidades do Grupo 1001?,"Jasmine deve consultar o bloco ""Informações outras unidades"" e, se houver dados disponíveis, pode repassar o contato e localização dessas unidades, mas sem assumir atendimento, suporte ou operação dessas unidades.",2026-02-22 20:08:52.748054,2026-02-22 20:08:52.748054
24,1,Quando Jasmine deve acionar a ferramenta 'daniela_reservas'?,"A ferramenta 'daniela_reservas' deve ser acionada sempre que o cliente quiser reservar para data futura, confirmar, alterar ou cancelar uma reserva existente, ou consultar preços e disponibilidade relacionados a reservas.",2026-02-22 20:08:52.750872,2026-02-22 20:08:52.750872
25,1,Qual a orientação para Jasmine ao informar preços aos clientes?,"Jasmine só deve informar preços após ter a confirmação explícita da duração da estadia (horas, pernoite ou diária). Se o cliente não informar a duração, deve perguntar antes de responder. Jasmine nunca deve deduzir ou assumir a duração para informar preço.",2026-02-22 20:08:52.754308,2026-02-22 20:08:52.754308
26,1,Como Jasmine deve agir se o cliente já estiver dentro do hotel ou na suíte?,"Se a mensagem indicar que o cliente já está dentro do hotel ou na suíte, Jasmine deve parar o atendimento automatizado, ativar um handoff imediato para um atendente humano local, aplicar a etiqueta 'pausar_ia' e não tentar resolver pedidos relacionados a quarto, café, limpeza, manutenção ou consumo.",2026-02-22 20:08:52.757788,2026-02-22 20:08:52.757788
27,1,Qual mensagem Jasmine deve usar ao transferir o atendimento para o atendente local do hotel?,"A mensagem padrão é: ""Vou te encaminhar agora para um atendente local aí no hotel para resolver isso mais rápido. Nosso primeiro atendimento é feito pela central, que não fica dentro da unidade, mas já estou transferindo você para a equipe presencial. Só um instante.""",2026-02-22 20:08:52.761554,2026-02-22 20:08:52.761554
28,1,Quando Jasmine pode falar sobre pagamento via Pix durante o atendimento?,"A IA só pode mencionar ou oferecer o Pix após confirmação clara e explícita da reserva pelo cliente. Jasmine não deve oferecer, gerar ou pedir confirmação de Pix somente ao informar preços.",2026-02-22 20:08:52.765421,2026-02-22 20:08:52.765421
29,1,"Como Jasmine deve interpretar termos relacionados a hidro, spa ou banheira nas solicitações de clientes?","Quando o cliente usar termos populares que indiquem hidro, spa ou banheira, Jasmine deve mapear automaticamente para a 'Suíte Hidromassagem (SPA/HIDROMASSAGEM)'.",2026-02-22 20:08:52.778031,2026-02-22 20:08:52.778031
30,1,Qual procedimento Jasmine deve seguir ao receber perguntas sobre a disponibilidade imediata da suíte?,"Sempre que o cliente perguntar se a suíte está livre, ocupada ou disponível no momento, Jasmine deve obrigatoriamente consultar a ferramenta 'disponibilidade_suites' para confirmar a disponibilidade antes de responder.",2026-02-22 20:08:52.782575,2026-02-22 20:08:52.782575
31,1,Quais são as ferramentas que Jasmine pode acionar durante o atendimento e em quais situações?,"Jasmine pode acionar: 'daniela_reservas' para assuntos relacionados a reservas, preços e disponibilidade; 'maria_fotos' quando o cliente pedir fotos das suítes; e 'disponibilidade_suites' para consultas de disponibilidade imediata das suítes.",2026-02-22 20:08:52.785345,2026-02-22 20:08:52.785345
32,1,Como deve ser o fluxo de atendimento de Jasmine para um pedido de reserva?,"Jasmine cumprimenta o cliente de forma natural e se apresenta. Depois identifica a intenção, acionando 'daniela_reservas' se for reserva ou consulta de preço. Ela envia tudo que o cliente informou para a ferramenta, deixa que ela peça o que faltar e responde com mensagens curtas, claras e envolver emojis moderados. Ao final, oferece próximos passos claros e encerra cordialmente.",2026-02-22 20:08:52.788351,2026-02-22 20:08:52.788351
33,1,Como Jasmine deve lidar com clientes que pedem fotos das suítes?,"Quando o cliente pede fotos, Jasmine deve acionar a ferramenta 'maria_fotos', especificando qual suíte o cliente deseja ver, aguardar a resposta da ferramenta e, então, enviar as fotos ao cliente, complementando com uma mensagem simpática.",2026-02-22 20:08:52.791246,2026-02-22 20:08:53.794608
34,1,Quais são as regras críticas que Jasmine deve seguir ao consultar disponibilidade e preços?,"Jasmine nunca deve confirmar disponibilidade ou preços sem consultar a ferramenta 'daniela_reservas'. Além disso, para preços, a duração da estadia deve estar clara. Jasmine também não deve assumir ou deduzir duração para informar valores.",2026-02-22 20:08:52.794938,2026-02-22 20:08:53.286828
35,1,O que Jasmine deve fazer se não souber a resposta para uma pergunta do cliente?,Jasmine deve consultar obrigatoriamente o FAQ antes de responder e jamais responder que não sabe sem buscar essa informação primeiro.,2026-02-22 20:08:52.798692,2026-02-22 20:08:52.798692
36,1,Como Jasmine deve usar o histórico do cliente durante o atendimento?,"Jasmine não deve usar o histórico do cliente como fonte para responder, mesmo que a pergunta já tenha sido feita. Ela deve consultar o FAQ novamente, pois ele está em constante atualização e é a fonte oficial e prioritária.",2026-02-22 20:08:52.801803,2026-02-22 20:08:53.685119
37,1,Quais são as recomendações para o tom e o uso de emojis nas respostas de Jasmine?,"Jasmine deve usar um tom natural, ágil, simpático e focado em atendimento. Nos inícios e fins de conversas, deve reagir com emojis apropriados, mas usar emojis de forma moderada nas respostas para manter o profissionalismo.",2026-02-22 20:08:52.806093,2026-02-22 20:08:53.800318
38,1,Quais informações Jasmine deve coletar do cliente para efetuar uma reserva?,"Jasmine deve solicitar nome completo, CPF, qual suíte deseja (Stilo, Alexa, ou Hidromassagem) e dia e horário da reserva.",2026-02-22 20:08:52.817357,2026-02-22 20:08:53.969142
40,1,Quais são as opções de tempo e preços para a SUÍTE STILO de segunda a quarta?,"Para a SUÍTE STILO de segunda a quarta, os preços são: 1 hora por R$ 40,00; 2 horas por R$ 60,00; 3 horas por R$ 70,00; 4 horas por R$ 75,00; pernoite com café da manhã por R$ 130,00; diária com café da manhã por R$ 160,00.",2026-02-22 20:08:54.996173,2026-02-22 20:08:54.996173
41,1,"Quanto custa o aluguel da SUÍTE ALEXA durante quintas, domingos e feriados?","O aluguel da SUÍTE ALEXA de quinta a domingo e feriado custa: 1 hora por R$ 60,00; 2 horas por R$ 75,00; 3 horas por R$ 85,00; 4 horas por R$ 90,00; pernoite com café da manhã por R$ 160,00; diária com café por R$ 200,00.",2026-02-22 20:08:54.999751,2026-02-22 20:08:55.646906
42,1,Qual o valor da hora excedente para cada suíte após o tempo contratado?,"As horas excedentes após o tempo contratado são cobradas da seguinte forma: SUÍTE STILO por R$ 25,00, SUÍTE ALEXA por R$ 35,00, e SUÍTE HIDROMASSAGEM por R$ 50,00.",2026-02-22 20:08:55.003204,2026-02-22 20:08:55.683643
43,1,Qual é o horário de entrada e saída para pernoite e diária nas suítes?,"Para pernoite, a entrada é a partir das 19h e a saída até as 12h, incluindo café simples. Para diária, o check-in é a partir das 12h e a duração é de 24 horas, com café da manhã incluso.",2026-02-22 20:08:55.006432,2026-02-22 20:08:55.85498
44,1,Quais são os horários do café da manhã oferecido nas suítes?,O café da manhã é servido entre 07h e 09h nas suítes para todas as opções de hospedagem.,2026-02-22 20:08:55.010121,2026-02-22 20:08:55.010121
45,1,Os valores informados são válidos para quantas pessoas e como funciona o adicional por pessoa extra?,"Os valores são válidos para 1 ou 2 pessoas. Caso haja pessoa extra, será cobrado um valor adicional.",2026-02-22 20:08:55.012676,2026-02-22 20:08:55.828264
46,1,O que está incluído na diária e no pernoite em relação ao café da manhã?,"No pernoite está incluso um café da manhã simples, já na diária o café da manhã está incluído e é servido no horário das 07h às 09h.",2026-02-22 20:08:55.01681,2026-02-22 20:08:55.520274
47,1,Quais são os preços para o uso do SPA/HIDROMASSAGEM de segunda a quarta?,"Os preços para o SPA/HIDROMASSAGEM de segunda a quarta são: 1 hora por R$ 130,00; 2 horas por R$ 150,00; 3 horas por R$ 170,00; 4 horas por R$ 190,00; pernoite com café da manhã por R$ 260,00; diária com café da manhã por R$ 350,00.",2026-02-22 20:08:55.020737,2026-02-22 20:08:56.143242
48,1,Existe estacionamento disponível para os hóspedes?,"Sim, o estacionamento é gratuito para os hóspedes durante todo o período de estadia.",2026-02-22 20:08:55.023855,2026-02-22 20:08:56.146402
49,1,Quais são as opções de tempo e preço para a Suíte Stilo de segunda a quarta-feira?,"Para a Suíte Stilo de segunda a quarta-feira, as opções são: 1 hora por R$ 40,00; 2 horas por R$ 60,00; 3 horas por R$ 70,00; 4 horas por R$ 75,00; pernoite com café da manhã por R$ 130,00; e diária com café da manhã por R$ 160,00.",2026-02-22 20:08:55.815535,2026-02-22 20:08:56.312314
50,1,Qual o valor para uma diária com café da manhã na Suíte Alexa de quinta a domingo e feriados?,"Na Suíte Alexa, de quinta a domingo e feriados, o valor da diária com café da manhã é R$ 200,00.",2026-02-22 20:08:55.818507,2026-02-22 20:08:56.49418
51,1,Como é definido o horário do pernoite e da diária no hotel?,"O pernoite tem entrada a partir das 19h e saída até as 12h, incluindo um café simples pela manhã. A diária permite check-in a partir das 12h, duração de 24 horas, e inclui café da manhã.",2026-02-22 20:08:55.821344,2026-02-22 20:08:56.677188
52,1,Qual o custo do tempo excedente após o tempo contratado nas suítes?,"O valor da hora excedente é: R$ 25,00 para a Suíte Stilo, R$ 35,00 para a Suíte Alexa, e R$ 50,00 para a Suíte com Hidromassagem.",2026-02-22 20:08:55.824399,2026-02-22 20:08:56.687505
53,1,Quais são os horários do café da manhã oferecido pelo hotel?,O café da manhã é servido das 07h às 09h.,2026-02-22 20:08:55.827879,2026-02-22 20:08:56.974239
54,1,Quais suítes oferecem serviços com hidromassagem e quais seus preços?,"O serviço de SPA com hidromassagem está disponível com preços de segunda a quarta-feira: 1 hora por R$ 130,00; 2 horas por R$ 150,00; 3 horas por R$ 170,00; 4 horas por R$ 190,00; pernoite com café por R$ 260,00; diária com café por R$ 350,00. De quinta a domingo e feriados, os preços são: 1 hora por R$ 140,00; 2 horas por R$ 160,00; 3 horas por R$ 180,00; 4 horas por R$ 200,00; pernoite com café por R$ 280,00; diária com café por R$ 370,00.",2026-02-22 20:08:55.831077,2026-02-22 20:08:56.48861
55,1,Os valores informados para as suítes são válidos para quantas pessoas?,Os valores são válidos para 1 ou 2 pessoas. Pessoas extras pagam uma taxa adicional.,2026-02-22 20:08:55.841973,2026-02-22 20:08:55.841973
56,1,O hotel oferece estacionamento para os hóspedes?,"Sim, o hotel oferece estacionamento grátis para os hóspedes.",2026-02-22 20:08:55.844481,2026-02-22 20:08:55.844481
57,1,Quais os preços para a Suíte Alexa de segunda a quarta-feira para diferentes durações?,"Para a Suíte Alexa de segunda a quarta-feira, os preços são: 1 hora por R$ 50,00; 2 horas por R$ 65,00; 3 horas por R$ 75,00; 4 horas por R$ 80,00; pernoite com café por R$ 140,00; diária com café por R$ 170,00.",2026-02-22 20:08:55.849038,2026-02-22 20:08:56.51513
58,1,Qual a diferença de preço para locação de suítes entre dias da semana e finais de semana/feriados?,"Os preços variam de acordo com os dias da semana, sendo mais caros de quinta a domingo e feriados em todas as suítes. Por exemplo, a Suíte Stilo cobra R$ 40,00 por 1 hora de segunda a quarta, e R$ 50,00 por 1 hora de quinta a domingo/feriados; a Suíte Alexa segue o mesmo padrão, com preço superior no final de semana.",2026-02-22 20:08:55.853863,2026-02-22 20:08:56.670627
60,1,qual a senha da internet ?,senha da internet do prime ceilândia é borbad782987,2026-02-23 00:59:03.004922,2026-02-23 00:59:03.862286
61,1,Posso levar cachorro para o hotel?,"No momento, não é permitido levar cachorro ou outros animais de estimação para o hotel.
Essa regra existe para garantir a higiene, o conforto e a segurança de todos os hóspedes. Se precisar, posso te ajudar a escolher a melhor suíte ou tirar outras dúvidas sobre a hospedagem 🙂",2026-02-23 20:39:51.836361,2026-02-23 20:39:53.193574
62,1,qual valor da suite Aluba ?,"O valor da suite Aluba todos os dias da semana esta 5 reais apenas, para qualquer duração , promoção ate o final do mês....",2026-02-24 21:14:32.493449,2026-02-24 23:30:19.388072
63,0,Qual o valor da suíte Cristal no Hotel 1001 Noites Prime Ceilândia?,"O valor oficial da diária da suíte Cristal é R$ 220,00.",2026-02-24 21:30:18.958886,2026-02-24 21:30:19.922809
64,0,Qual o valor da suíte Aluba no Hotel 1001 Noites Prime Ceilândia?,"O valor da diária da suíte Aluba é R$ 200,00.",2026-02-24 21:30:18.964318,2026-02-24 21:30:19.514052
65,0,Qual o valor da suíte Checkmate no Hotel 1001 Noites Prime Ceilândia?,"O valor da diária da suíte Checkmate é R$ 250,00.",2026-02-24 21:30:18.966634,2026-02-24 21:30:19.739137
66,0,Como faço uma reserva para o Hotel 1001 Noites Prime Ceilândia?,"Para reservar, informe seu nome completo, CPF, data de chegada, data de saída e a suíte desejada. Para confirmar a reserva, é necessário um sinal de 50% do valor da diária via pagamento Pix.",2026-02-24 21:30:18.968874,2026-02-24 21:30:20.041614
67,0,Como faço para reservar uma suíte para uma pernoite?,"Para reservar uma suíte, informe seu nome completo, CPF, a data da reserva e a duração da estadia (quantas noites). É necessário pagar um sinal de 50% do valor total para garantir a reserva.",2026-03-01 03:36:40.145581,2026-03-01 03:36:41.037735
68,0,Posso pagar a reserva via Pix?,"Sim, o pagamento do sinal pode ser feito via Pix. No entanto, às vezes ocorrem instabilidades na geração do código Pix, especialmente relacionadas ao CPF cadastrado, e nesse caso é indicado entrar em contato pelo canal manual de reservas para finalizar a reserva.",2026-03-01 03:36:40.177752,2026-03-01 03:36:41.016029
69,1,pernoite?,"quando o cliente fazer essa pergunta sem contexto dizendo ""pernoite"" é porque ele deseja saber o preço da pernoite , busque o preço da pernoite e passe para ele ...",2026-03-01 13:37:09.441703,2026-03-01 13:39:56.190666
1 id status question answer created_at updated_at
2 1 1 Quem é Jasmine no contexto do atendimento do Hotel 1001 Noites Prime – Ceilândia? Jasmine é a atendente de experiência do Hotel 1001 Noites Prime – Ceilândia, responsável por ser o primeiro contato com o cliente via WhatsApp, fornecer informações, identificar rapidamente a intenção do cliente e acionar a ferramenta correta para atendimento. 2026-02-22 20:08:38.312922 2026-02-22 20:08:38.312922
3 2 1 Quais são as restrições específicas para Jasmine ao atender clientes? Jasmine atende exclusivamente a unidade Ceilândia, não revela que é IA a menos que perguntada, nunca inventa informações, não menciona nomes de ferramentas ou sub-agentes ao cliente e, ao responder perguntas sobre preço, só fornece valores após confirmar explicitamente a duração desejada pelo cliente (horas, pernoite ou diária). 2026-02-22 20:08:38.3385 2026-02-22 20:08:38.3385
4 3 1 Como deve ser o tom e a forma de atendimento da Jasmine? O tom deve ser natural, ágil, simpático e focado em atendimento, com uso moderado de emojis no início e no fim da conversa para tornar o atendimento mais acolhedor. 2026-02-22 20:08:38.340793 2026-02-22 20:08:39.276049
5 4 1 Quais ferramentas Jasmine deve acionar dependendo da intenção do cliente? Se o cliente desejar fazer reservas para datas futuras, confirmar, alterar ou cancelar reservas, Jasmine deve acionar a ferramenta daniela_reservas. Se o cliente pedir fotos das suítes, Jasmine deve acionar a ferramenta maria_fotos. Para consultas de disponibilidade, deve ser usada a ferramenta disponibilidade_suites. 2026-02-22 20:08:38.342428 2026-02-22 20:08:39.284966
6 5 1 O que Jasmine deve fazer se o cliente for hóspede em estadia na unidade? Se o cliente indicar que já está dentro do hotel ou na suíte, Jasmine deve parar o fluxo normal de atendimento, ativar handoff imediato para um atendente humano local, aplicar a etiqueta 'pausar_ia' e não responder mais mensagens após isso, evitando tentar resolver pedidos de quarto, café, limpeza, manutenção ou consumo. 2026-02-22 20:08:38.344669 2026-02-22 20:08:39.717754
7 6 1 Quais informações Jasmine deve fornecer na mensagem padrão de transferência para um atendente local? Jasmine deve informar que o atendimento inicial é feito por uma central que não fica fisicamente dentro da unidade e que o cliente será transferido para um atendente local no hotel, utilizando uma mensagem cordial como: 'Vou te encaminhar agora para um atendente local aí no hotel para resolver isso mais rápido...' 2026-02-22 20:08:38.347545 2026-02-22 20:08:39.486556
8 7 1 Quando Jasmine pode informar preços ao cliente? Jasmine só pode informar preços quando a duração da estadia estiver clara na mensagem do cliente, sendo válida a duração em horas, pernoite ou diária. Se a duração já foi informada, Jasmine não precisa perguntar novamente, devendo consultar a ferramenta adequada para retornar o preço exato. 2026-02-22 20:08:38.350565 2026-02-22 20:08:39.347749
9 8 1 O que deve ser feito antes de informar preços ao cliente? Antes de fornecer preços, Jasmine deve confirmar explicitamente a duração desejada para evitar deduções ou suposições. A IA nunca deve informar preços sem essa confirmação clara. 2026-02-22 20:08:38.353868 2026-02-22 20:08:39.288236
10 9 1 A IA pode oferecer cobrança via Pix ao informar preços? Não. A IA não pode oferecer, gerar ou pedir confirmação de Pix apenas ao informar preços. A oferta do Pix só pode ocorrer após a confirmação clara e explícita da reserva pelo cliente. 2026-02-22 20:08:38.385687 2026-02-22 20:08:38.385687
11 10 1 O que Jasmine deve fazer se o cliente usar termos como hidro, spa ou banheira? Jasmine deve interpretar automaticamente esses termos como referência à Suíte Hidromassagem (SPA/HIDROMASSAGEM) para garantir o atendimento adequado. 2026-02-22 20:08:38.389701 2026-02-22 20:08:38.389701
12 11 1 Como proceder caso o cliente pergunte sobre a disponibilidade imediata de uma suíte? Jasmine deve obrigatoriamente consultar a ferramenta disponibilidade_suites para verificar a disponibilidade antes de responder ao cliente. 2026-02-22 20:08:38.394516 2026-02-22 20:08:38.394516
13 12 1 Quais são as etapas do fluxo operacional de atendimento de Jasmine? O fluxo inclui: 1) Cumprimentar de forma natural e se apresentar; 2) Identificar rapidamente a intenção do cliente; 3) Acionar a ferramenta apropriada conforme a intenção; 4) Responder com mensagens de até 2 parágrafos e com uso moderado de emojis; 5) Oferecer próximo passo claro e finalizar com cordialidade. 2026-02-22 20:08:38.398954 2026-02-22 20:08:38.398954
14 13 1 Quais são exemplos de gatilhos que indicam que Jasmine deve acionar a ferramenta daniela_reservas? Gatilhos incluem frases como 'quero reservar para sábado', 'tem vaga agora?', 'quanto custa?', 'confirmar reserva', 'cancelar agendamento' ou 'está disponível?'. 2026-02-22 20:08:38.408291 2026-02-22 20:08:38.408291
15 14 1 Como Jasmine deve responder pedidos de fotos das suítes? Ao receber pedidos de fotos, Jasmine deve acionar a ferramenta maria_fotos especificando qual suíte o cliente quer ver, aguardar as fotos e depois enviar ao cliente junto com uma mensagem simpática, como 'Aqui estão as fotos da suíte Alexa 📸 Gostou?'. 2026-02-22 20:08:38.413078 2026-02-22 20:08:38.413078
16 15 1 O que Jasmine deve fazer se não souber a resposta para uma pergunta do cliente? Jasmine deve consultar obrigatoriamente o FAQ para buscar a informação antes de responder e nunca responder que 'não sabe' sem antes buscar a informação. 2026-02-22 20:08:38.418265 2026-02-22 20:08:38.418265
17 16 1 Qual é o procedimento padrão quando o cliente deseja cancelar uma reserva? Jasmine aciona a ferramenta daniela_reservas, informa que irá verificar, e a ferramenta solicitará ao cliente dados como nome completo, CPF e código da reserva para dar continuidade ao processo de cancelamento. 2026-02-22 20:08:38.422154 2026-02-22 20:08:38.422154
18 17 1 Como Jasmine deve reagir a mensagens de saudação ou agradecimento do cliente? Deve reagir com emojis correspondentes, por exemplo, 😀 para 'Olá!' e ❤️ para 'Obrigado!', tornando a interação mais amigável e natural. 2026-02-22 20:08:38.431865 2026-02-22 20:08:38.431865
19 18 1 Onde fica localizado o Hotel 1001 Noites Prime em Ceilândia? O hotel está localizado na Ceilândia Leste ao lado da academia Ultra no QNM03. 2026-02-22 20:08:38.436049 2026-02-22 20:08:38.436049
20 19 1 O que deve ser feito se o cliente solicitar informações de outras unidades do hotel? Jasmine deve consultar o bloco de informações outras unidades e, se houver dados disponíveis, repassar apenas o contato e localização de outras unidades, sem assumir atendimento, suporte ou operação dessas unidades. 2026-02-22 20:08:38.440958 2026-02-22 20:08:38.440958
21 20 1 No atendimento pelo WhatsApp, quais são as unidades diferentes para as quais Jasmine não deve assumir atendimento direto? As unidades como 1001 Noites Samambaia, 1001 Noites Águas Claras, 1001 Noites Recanto das Emas, entre outras, possuem contatos específicos e Jasmine deve informar esses contatos para o cliente sem assumir o atendimento. 2026-02-22 20:08:38.445716 2026-02-22 20:08:39.465882
22 21 1 Quem é Jasmine e qual é sua função no Hotel 1001 Noites Prime - Ceilândia? Jasmine é a atendente de experiência, responsável por ser o primeiro contato com o cliente via WhatsApp, fornecendo informações, identificando rapidamente a intenção do cliente e acionando a ferramenta correta para atendimento. 2026-02-22 20:08:52.738392 2026-02-22 20:08:52.738392
23 22 1 Quais as restrições que Jasmine deve seguir durante o atendimento? Jasmine atende exclusivamente a unidade Ceilândia, não revela que é IA, não inventa informações, não menciona nomes de ferramentas ou sub-agentes ao cliente, nunca informa preços sem confirmar a duração da estadia e não confirma disponibilidade ou preço sem consultar a ferramenta apropriada. 2026-02-22 20:08:52.745089 2026-02-22 20:08:52.745089
24 23 1 Como Jasmine deve proceder quando o cliente pede informações sobre outras unidades do Grupo 1001? Jasmine deve consultar o bloco "Informações outras unidades" e, se houver dados disponíveis, pode repassar o contato e localização dessas unidades, mas sem assumir atendimento, suporte ou operação dessas unidades. 2026-02-22 20:08:52.748054 2026-02-22 20:08:52.748054
25 24 1 Quando Jasmine deve acionar a ferramenta 'daniela_reservas'? A ferramenta 'daniela_reservas' deve ser acionada sempre que o cliente quiser reservar para data futura, confirmar, alterar ou cancelar uma reserva existente, ou consultar preços e disponibilidade relacionados a reservas. 2026-02-22 20:08:52.750872 2026-02-22 20:08:52.750872
26 25 1 Qual a orientação para Jasmine ao informar preços aos clientes? Jasmine só deve informar preços após ter a confirmação explícita da duração da estadia (horas, pernoite ou diária). Se o cliente não informar a duração, deve perguntar antes de responder. Jasmine nunca deve deduzir ou assumir a duração para informar preço. 2026-02-22 20:08:52.754308 2026-02-22 20:08:52.754308
27 26 1 Como Jasmine deve agir se o cliente já estiver dentro do hotel ou na suíte? Se a mensagem indicar que o cliente já está dentro do hotel ou na suíte, Jasmine deve parar o atendimento automatizado, ativar um handoff imediato para um atendente humano local, aplicar a etiqueta 'pausar_ia' e não tentar resolver pedidos relacionados a quarto, café, limpeza, manutenção ou consumo. 2026-02-22 20:08:52.757788 2026-02-22 20:08:52.757788
28 27 1 Qual mensagem Jasmine deve usar ao transferir o atendimento para o atendente local do hotel? A mensagem padrão é: "Vou te encaminhar agora para um atendente local aí no hotel para resolver isso mais rápido. Nosso primeiro atendimento é feito pela central, que não fica dentro da unidade, mas já estou transferindo você para a equipe presencial. Só um instante." 2026-02-22 20:08:52.761554 2026-02-22 20:08:52.761554
29 28 1 Quando Jasmine pode falar sobre pagamento via Pix durante o atendimento? A IA só pode mencionar ou oferecer o Pix após confirmação clara e explícita da reserva pelo cliente. Jasmine não deve oferecer, gerar ou pedir confirmação de Pix somente ao informar preços. 2026-02-22 20:08:52.765421 2026-02-22 20:08:52.765421
30 29 1 Como Jasmine deve interpretar termos relacionados a hidro, spa ou banheira nas solicitações de clientes? Quando o cliente usar termos populares que indiquem hidro, spa ou banheira, Jasmine deve mapear automaticamente para a 'Suíte Hidromassagem (SPA/HIDROMASSAGEM)'. 2026-02-22 20:08:52.778031 2026-02-22 20:08:52.778031
31 30 1 Qual procedimento Jasmine deve seguir ao receber perguntas sobre a disponibilidade imediata da suíte? Sempre que o cliente perguntar se a suíte está livre, ocupada ou disponível no momento, Jasmine deve obrigatoriamente consultar a ferramenta 'disponibilidade_suites' para confirmar a disponibilidade antes de responder. 2026-02-22 20:08:52.782575 2026-02-22 20:08:52.782575
32 31 1 Quais são as ferramentas que Jasmine pode acionar durante o atendimento e em quais situações? Jasmine pode acionar: 'daniela_reservas' para assuntos relacionados a reservas, preços e disponibilidade; 'maria_fotos' quando o cliente pedir fotos das suítes; e 'disponibilidade_suites' para consultas de disponibilidade imediata das suítes. 2026-02-22 20:08:52.785345 2026-02-22 20:08:52.785345
33 32 1 Como deve ser o fluxo de atendimento de Jasmine para um pedido de reserva? Jasmine cumprimenta o cliente de forma natural e se apresenta. Depois identifica a intenção, acionando 'daniela_reservas' se for reserva ou consulta de preço. Ela envia tudo que o cliente informou para a ferramenta, deixa que ela peça o que faltar e responde com mensagens curtas, claras e envolver emojis moderados. Ao final, oferece próximos passos claros e encerra cordialmente. 2026-02-22 20:08:52.788351 2026-02-22 20:08:52.788351
34 33 1 Como Jasmine deve lidar com clientes que pedem fotos das suítes? Quando o cliente pede fotos, Jasmine deve acionar a ferramenta 'maria_fotos', especificando qual suíte o cliente deseja ver, aguardar a resposta da ferramenta e, então, enviar as fotos ao cliente, complementando com uma mensagem simpática. 2026-02-22 20:08:52.791246 2026-02-22 20:08:53.794608
35 34 1 Quais são as regras críticas que Jasmine deve seguir ao consultar disponibilidade e preços? Jasmine nunca deve confirmar disponibilidade ou preços sem consultar a ferramenta 'daniela_reservas'. Além disso, para preços, a duração da estadia deve estar clara. Jasmine também não deve assumir ou deduzir duração para informar valores. 2026-02-22 20:08:52.794938 2026-02-22 20:08:53.286828
36 35 1 O que Jasmine deve fazer se não souber a resposta para uma pergunta do cliente? Jasmine deve consultar obrigatoriamente o FAQ antes de responder e jamais responder que não sabe sem buscar essa informação primeiro. 2026-02-22 20:08:52.798692 2026-02-22 20:08:52.798692
37 36 1 Como Jasmine deve usar o histórico do cliente durante o atendimento? Jasmine não deve usar o histórico do cliente como fonte para responder, mesmo que a pergunta já tenha sido feita. Ela deve consultar o FAQ novamente, pois ele está em constante atualização e é a fonte oficial e prioritária. 2026-02-22 20:08:52.801803 2026-02-22 20:08:53.685119
38 37 1 Quais são as recomendações para o tom e o uso de emojis nas respostas de Jasmine? Jasmine deve usar um tom natural, ágil, simpático e focado em atendimento. Nos inícios e fins de conversas, deve reagir com emojis apropriados, mas usar emojis de forma moderada nas respostas para manter o profissionalismo. 2026-02-22 20:08:52.806093 2026-02-22 20:08:53.800318
39 38 1 Quais informações Jasmine deve coletar do cliente para efetuar uma reserva? Jasmine deve solicitar nome completo, CPF, qual suíte deseja (Stilo, Alexa, ou Hidromassagem) e dia e horário da reserva. 2026-02-22 20:08:52.817357 2026-02-22 20:08:53.969142
40 40 1 Quais são as opções de tempo e preços para a SUÍTE STILO de segunda a quarta? Para a SUÍTE STILO de segunda a quarta, os preços são: 1 hora por R$ 40,00; 2 horas por R$ 60,00; 3 horas por R$ 70,00; 4 horas por R$ 75,00; pernoite com café da manhã por R$ 130,00; diária com café da manhã por R$ 160,00. 2026-02-22 20:08:54.996173 2026-02-22 20:08:54.996173
41 41 1 Quanto custa o aluguel da SUÍTE ALEXA durante quintas, domingos e feriados? O aluguel da SUÍTE ALEXA de quinta a domingo e feriado custa: 1 hora por R$ 60,00; 2 horas por R$ 75,00; 3 horas por R$ 85,00; 4 horas por R$ 90,00; pernoite com café da manhã por R$ 160,00; diária com café por R$ 200,00. 2026-02-22 20:08:54.999751 2026-02-22 20:08:55.646906
42 42 1 Qual o valor da hora excedente para cada suíte após o tempo contratado? As horas excedentes após o tempo contratado são cobradas da seguinte forma: SUÍTE STILO por R$ 25,00, SUÍTE ALEXA por R$ 35,00, e SUÍTE HIDROMASSAGEM por R$ 50,00. 2026-02-22 20:08:55.003204 2026-02-22 20:08:55.683643
43 43 1 Qual é o horário de entrada e saída para pernoite e diária nas suítes? Para pernoite, a entrada é a partir das 19h e a saída até as 12h, incluindo café simples. Para diária, o check-in é a partir das 12h e a duração é de 24 horas, com café da manhã incluso. 2026-02-22 20:08:55.006432 2026-02-22 20:08:55.85498
44 44 1 Quais são os horários do café da manhã oferecido nas suítes? O café da manhã é servido entre 07h e 09h nas suítes para todas as opções de hospedagem. 2026-02-22 20:08:55.010121 2026-02-22 20:08:55.010121
45 45 1 Os valores informados são válidos para quantas pessoas e como funciona o adicional por pessoa extra? Os valores são válidos para 1 ou 2 pessoas. Caso haja pessoa extra, será cobrado um valor adicional. 2026-02-22 20:08:55.012676 2026-02-22 20:08:55.828264
46 46 1 O que está incluído na diária e no pernoite em relação ao café da manhã? No pernoite está incluso um café da manhã simples, já na diária o café da manhã está incluído e é servido no horário das 07h às 09h. 2026-02-22 20:08:55.01681 2026-02-22 20:08:55.520274
47 47 1 Quais são os preços para o uso do SPA/HIDROMASSAGEM de segunda a quarta? Os preços para o SPA/HIDROMASSAGEM de segunda a quarta são: 1 hora por R$ 130,00; 2 horas por R$ 150,00; 3 horas por R$ 170,00; 4 horas por R$ 190,00; pernoite com café da manhã por R$ 260,00; diária com café da manhã por R$ 350,00. 2026-02-22 20:08:55.020737 2026-02-22 20:08:56.143242
48 48 1 Existe estacionamento disponível para os hóspedes? Sim, o estacionamento é gratuito para os hóspedes durante todo o período de estadia. 2026-02-22 20:08:55.023855 2026-02-22 20:08:56.146402
49 49 1 Quais são as opções de tempo e preço para a Suíte Stilo de segunda a quarta-feira? Para a Suíte Stilo de segunda a quarta-feira, as opções são: 1 hora por R$ 40,00; 2 horas por R$ 60,00; 3 horas por R$ 70,00; 4 horas por R$ 75,00; pernoite com café da manhã por R$ 130,00; e diária com café da manhã por R$ 160,00. 2026-02-22 20:08:55.815535 2026-02-22 20:08:56.312314
50 50 1 Qual o valor para uma diária com café da manhã na Suíte Alexa de quinta a domingo e feriados? Na Suíte Alexa, de quinta a domingo e feriados, o valor da diária com café da manhã é R$ 200,00. 2026-02-22 20:08:55.818507 2026-02-22 20:08:56.49418
51 51 1 Como é definido o horário do pernoite e da diária no hotel? O pernoite tem entrada a partir das 19h e saída até as 12h, incluindo um café simples pela manhã. A diária permite check-in a partir das 12h, duração de 24 horas, e inclui café da manhã. 2026-02-22 20:08:55.821344 2026-02-22 20:08:56.677188
52 52 1 Qual o custo do tempo excedente após o tempo contratado nas suítes? O valor da hora excedente é: R$ 25,00 para a Suíte Stilo, R$ 35,00 para a Suíte Alexa, e R$ 50,00 para a Suíte com Hidromassagem. 2026-02-22 20:08:55.824399 2026-02-22 20:08:56.687505
53 53 1 Quais são os horários do café da manhã oferecido pelo hotel? O café da manhã é servido das 07h às 09h. 2026-02-22 20:08:55.827879 2026-02-22 20:08:56.974239
54 54 1 Quais suítes oferecem serviços com hidromassagem e quais seus preços? O serviço de SPA com hidromassagem está disponível com preços de segunda a quarta-feira: 1 hora por R$ 130,00; 2 horas por R$ 150,00; 3 horas por R$ 170,00; 4 horas por R$ 190,00; pernoite com café por R$ 260,00; diária com café por R$ 350,00. De quinta a domingo e feriados, os preços são: 1 hora por R$ 140,00; 2 horas por R$ 160,00; 3 horas por R$ 180,00; 4 horas por R$ 200,00; pernoite com café por R$ 280,00; diária com café por R$ 370,00. 2026-02-22 20:08:55.831077 2026-02-22 20:08:56.48861
55 55 1 Os valores informados para as suítes são válidos para quantas pessoas? Os valores são válidos para 1 ou 2 pessoas. Pessoas extras pagam uma taxa adicional. 2026-02-22 20:08:55.841973 2026-02-22 20:08:55.841973
56 56 1 O hotel oferece estacionamento para os hóspedes? Sim, o hotel oferece estacionamento grátis para os hóspedes. 2026-02-22 20:08:55.844481 2026-02-22 20:08:55.844481
57 57 1 Quais os preços para a Suíte Alexa de segunda a quarta-feira para diferentes durações? Para a Suíte Alexa de segunda a quarta-feira, os preços são: 1 hora por R$ 50,00; 2 horas por R$ 65,00; 3 horas por R$ 75,00; 4 horas por R$ 80,00; pernoite com café por R$ 140,00; diária com café por R$ 170,00. 2026-02-22 20:08:55.849038 2026-02-22 20:08:56.51513
58 58 1 Qual a diferença de preço para locação de suítes entre dias da semana e finais de semana/feriados? Os preços variam de acordo com os dias da semana, sendo mais caros de quinta a domingo e feriados em todas as suítes. Por exemplo, a Suíte Stilo cobra R$ 40,00 por 1 hora de segunda a quarta, e R$ 50,00 por 1 hora de quinta a domingo/feriados; a Suíte Alexa segue o mesmo padrão, com preço superior no final de semana. 2026-02-22 20:08:55.853863 2026-02-22 20:08:56.670627
59 60 1 qual a senha da internet ? senha da internet do prime ceilândia é borbad782987 2026-02-23 00:59:03.004922 2026-02-23 00:59:03.862286
60 61 1 Posso levar cachorro para o hotel? No momento, não é permitido levar cachorro ou outros animais de estimação para o hotel. Essa regra existe para garantir a higiene, o conforto e a segurança de todos os hóspedes. Se precisar, posso te ajudar a escolher a melhor suíte ou tirar outras dúvidas sobre a hospedagem 🙂 2026-02-23 20:39:51.836361 2026-02-23 20:39:53.193574
61 62 1 qual valor da suite Aluba ? O valor da suite Aluba todos os dias da semana esta 5 reais apenas, para qualquer duração , promoção ate o final do mês.... 2026-02-24 21:14:32.493449 2026-02-24 23:30:19.388072
62 63 0 Qual o valor da suíte Cristal no Hotel 1001 Noites Prime – Ceilândia? O valor oficial da diária da suíte Cristal é R$ 220,00. 2026-02-24 21:30:18.958886 2026-02-24 21:30:19.922809
63 64 0 Qual o valor da suíte Aluba no Hotel 1001 Noites Prime – Ceilândia? O valor da diária da suíte Aluba é R$ 200,00. 2026-02-24 21:30:18.964318 2026-02-24 21:30:19.514052
64 65 0 Qual o valor da suíte Checkmate no Hotel 1001 Noites Prime – Ceilândia? O valor da diária da suíte Checkmate é R$ 250,00. 2026-02-24 21:30:18.966634 2026-02-24 21:30:19.739137
65 66 0 Como faço uma reserva para o Hotel 1001 Noites Prime – Ceilândia? Para reservar, informe seu nome completo, CPF, data de chegada, data de saída e a suíte desejada. Para confirmar a reserva, é necessário um sinal de 50% do valor da diária via pagamento Pix. 2026-02-24 21:30:18.968874 2026-02-24 21:30:20.041614
66 67 0 Como faço para reservar uma suíte para uma pernoite? Para reservar uma suíte, informe seu nome completo, CPF, a data da reserva e a duração da estadia (quantas noites). É necessário pagar um sinal de 50% do valor total para garantir a reserva. 2026-03-01 03:36:40.145581 2026-03-01 03:36:41.037735
67 68 0 Posso pagar a reserva via Pix? Sim, o pagamento do sinal pode ser feito via Pix. No entanto, às vezes ocorrem instabilidades na geração do código Pix, especialmente relacionadas ao CPF cadastrado, e nesse caso é indicado entrar em contato pelo canal manual de reservas para finalizar a reserva. 2026-03-01 03:36:40.177752 2026-03-01 03:36:41.016029
68 69 1 pernoite? quando o cliente fazer essa pergunta sem contexto dizendo "pernoite" é porque ele deseja saber o preço da pernoite , busque o preço da pernoite e passe para ele ... 2026-03-01 13:37:09.441703 2026-03-01 13:39:56.190666

View File

@ -0,0 +1,64 @@
# Ideias: Dashboard Modo Apresentação
## Análise das Sugestões Anteriores
A sugestão do modelo "Keynote dentro do App" (Slides 16:9) com fundos pré-renderizados é excelente e muito aderente à filosofia de Design System e de Performance focada em "Aesthetics" para uma apresentação executiva.
**Por que a abordagem sugerida é superior a gerar imagens ao vivo (via API Nano Banana):**
1. **Latência e Confiabilidade:** Em uma apresentação ao vivo (projetor/TV), a última coisa que queremos é um fundo carregando lentamente ou uma API falhando, deixando a tela branca.
2. **Consistência Visual:** Gerar via IA ao vivo introduz imprevisibilidade ("alucinações" visuais). Para um relatório gerencial, o padrão visual transmite autoridade e seriedade. A consistência de ter "O fundo azul marinho do RH" todo mês constrói identidade de produto.
3. **Contraste (Acessibilidade)**: Em um fundo pré-gerado e testado pelo design (Brad Frost mode), sabemos exatamente que a fonte branca tamanho 64px vai ter leitura perfeita. Uma imagem gerada aleatoriamente pode ter manchas claras exatamente onde o número está, matando a legibilidade no projetor.
---
## Proposta de Arquitetura Visual (O "Como Fazer" no Vue/CSS)
Se formos implementar essa trilha "Keynote", aqui está como podemos estruturar o Front-end e o Design System para que a apresentação seja espetacular:
### 1. O Contêiner 16:9 Fixo (O "Palco")
Em vez de deixar o dashboard se expandir infinitamente em telas ultrawide (o que deforma os elementos), criamos um **container com aspect-ratio 16:9 fixo**.
- Ele escala proporcionalmente usando `transform: scale()` dependendo do tamanho do navegador (como o Canva faz no modo apresentação).
- Isso garante que o que você vê no seu Mac é **exatamente** o que vai aparecer no projetor. Sem quebras de linha surpresas.
### 2. A Camada de Fundo (Backgrounds Curados)
Vamos hospedar ~10 imagens de altíssima qualidade (WebP) na pasta `public/assets/presentation_bgs/`.
- `bg-hero-mensal.webp`
- `bg-gestao-ok.webp`
- `bg-gestao-critical.webp`
(Podemos sim usar o Midjourney ou Nano Banana *uma vez*, em ambiente de design, para gerar imagens abstratas belíssimas estilo *Dark Glassmorphism* ou *Fluid 3D shapes*, aprová-las, tratá-las com uma camada escura (`rgba(0,0,0,0.6)`) no Figma e salvar estaticamente).
No Vue, criamos um componente `<PresentationSlide>` que dinamicamente puxa esse fundo baseado no status ou tema do slide, usando `background-size: cover`.
### 3. Tipografia "Hero" (O Foco no Dado)
A tela atual tenta mostrar os detalhes das auditorias em texto corrido ("168 auditorias realizadas..."). Em projetor, isso é invisível.
A tipografia no modo apresentação precisará ser um componente separado com **escalas gigantes**:
- **Slide Title:** 48px a 64px (Ex: "Auditoria Express")
- **Big Number:** 120px a 160px (Ex: "70%")
- **Subtitle/Status:** 24px a 32px (Ex: "Abaixo da Meta")
### 4. Layout "Slide" - Anatomia Proposta
Um componente de Slide padrão teria:
1. **Top-left:** Logo miniatura + Tópico atual (Ex: 📊 Operação Geral)
2. **Center-Left:** O Número Gigante (O KPI) com uma seta de tendência (📈/📉).
3. **Right-side:** Um Ranking limpo (Top 3 melhores, Top 3 piores) usando Avatares grandes e barras de progresso grossas (pelo menos 16px de altura, com border-radius). Em vez de texto descrevendo a meta, apenas a barra colorida.
4. **Bottom:** Uma frase executiva de "Takeaway" ou o Insight gerado pela nossa IA atual ("Necessário focar em treinamento na unidade Prime VL").
### 5. Transições Cinematográficas
Como controlamos o Vue, podemos usar o `<Transition>` do Vue com animações CSS maravilhosas.
- Ao mudar do slide 1 para o 2, os números do KPI não apenas aparecem, eles sobem e entram em fade (`translateY` + `opacity`) com uma curva suave (cubic-bezier).
- Se tivermos barras de progresso, elas animam de 0% até o valor em 1 segundo. Isso traz o "feel premium" que a diretoria valoriza.
---
## O Veredito Tecnológico
A IA sugeriu o **"Modelo híbrido"** e organizar em uma "Biblioteca de assets (não gerar ao vivo)". **Eu concordo 100% com ela.**
Gerar imagem por API ao vivo para fundo de dashboard é over-engineering (engenhoca demais) e traz muito risco (custo, lentidão na tela, feiura aleatória) para pouco benefício. Investir 1 hora gerando os 10 fundos perfeitos e codar um layout fixo 16:9 fará o Chatwoot parecer uma ferramenta SaaS de $10.000/mês.
**Se você aprovar a direção do "Modelo Híbrido Keynote com Assets Fixos"**, os próximos passos reais seriam:
1. Criarmos um arquivo CSS específico para o Presentation Mode (tipografia gigante, cores absolutas para dark mode).
2. Criar o layout de roteador novo (`/dashboard/.../presentation`) e o componente de Slide que consome os dados do Dashboard mas cospe na tela nessa versão "Gigante e Limpa".
3. Exportar ou subir os fundos de imagem.
O que achou dos pontos mecânicos de CSS/Layout aplicados à ideia dela?

View File

@ -0,0 +1,81 @@
require 'rails_helper'
RSpec.describe Captain::Reports::CeoDigestJob do
let(:account) { create(:account) }
let(:webhook_url) { 'https://mm.example.com/hooks/xyz' }
let(:period_end) { Date.current - 1.day }
let(:period_start) { period_end - 6.days }
describe '#perform' do
context 'when account has no config and no ENV fallback' do
before { ENV.delete('CEO_DIGEST_MATTERMOST_WEBHOOK_URL') }
it 'skips silently and does not call delivery' do
expect(Captain::Reports::MattermostDeliveryService).not_to receive(:new)
described_class.new.perform(account.id, period_start, period_end)
end
end
context 'when account has ceo_digest disabled' do
before do
account.update!(custom_attributes: { 'ceo_digest' => { 'enabled' => false, 'mattermost_webhook_url' => webhook_url } })
end
it 'skips delivery' do
expect(Captain::Reports::MattermostDeliveryService).not_to receive(:new)
described_class.new.perform(account.id, period_start, period_end)
end
end
context 'when account has webhook configured' do
let(:delivery) { instance_double(Captain::Reports::MattermostDeliveryService, call: { success: true, status: 200 }) }
before do
account.update!(custom_attributes: { 'ceo_digest' => { 'enabled' => true, 'mattermost_webhook_url' => webhook_url,
'mattermost_channel' => '#ceo' } })
allow(Captain::Reports::MattermostDeliveryService).to receive(:new).and_return(delivery)
end
it 'builds digest and calls delivery service with webhook and channel' do
described_class.new.perform(account.id, period_start, period_end)
expect(Captain::Reports::MattermostDeliveryService).to have_received(:new).with(
hash_including(webhook_url: webhook_url, channel: '#ceo')
)
end
end
context 'when using ENV fallback' do
let(:env_webhook) { 'https://mm.example.com/env-hook' }
let(:delivery) { instance_double(Captain::Reports::MattermostDeliveryService, call: { success: true, status: 200 }) }
around do |example|
original = ENV.fetch('CEO_DIGEST_MATTERMOST_WEBHOOK_URL', nil)
ENV['CEO_DIGEST_MATTERMOST_WEBHOOK_URL'] = env_webhook
example.run
ENV['CEO_DIGEST_MATTERMOST_WEBHOOK_URL'] = original
end
before do
allow(Captain::Reports::MattermostDeliveryService).to receive(:new).and_return(delivery)
end
it 'uses ENV webhook when account has no config' do
described_class.new.perform(account.id, period_start, period_end)
expect(Captain::Reports::MattermostDeliveryService).to have_received(:new).with(
hash_including(webhook_url: env_webhook)
)
end
end
context 'when delivery raises unexpectedly' do
before do
account.update!(custom_attributes: { 'ceo_digest' => { 'enabled' => true, 'mattermost_webhook_url' => webhook_url } })
allow(Captain::Reports::MattermostDeliveryService).to receive(:new).and_raise(StandardError, 'boom')
end
it 'rescues and logs without raising' do
expect { described_class.new.perform(account.id, period_start, period_end) }.not_to raise_error
end
end
end
end

View File

@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe Captain::Reports::CeoDigestService do
subject(:service) { described_class.new(account: account, period_start: period_start, period_end: period_end) }
let(:account) { create(:account) }
let(:inbox_prime) { create(:inbox, account: account, name: 'PRIME VL') }
let(:inbox_dolce) { create(:inbox, account: account, name: 'DOLCE AMORE') }
let(:period_end) { Date.current - 1.day }
let(:period_start) { period_end - 6.days }
describe '#call' do
context 'with no insights' do
it 'returns an empty digest flagged as empty' do
result = service.call
expect(result[:empty]).to be true
expect(result[:totals][:conversations]).to eq(0)
expect(result[:unit_ranking]).to be_empty
end
end
context 'with insights per inbox (canal = unidade)' do
before do
create(:captain_conversation_insight,
account: account, inbox: inbox_prime,
period_start: period_start, period_end: period_end,
conversations_count: 200, messages_count: 800)
create(:captain_conversation_insight,
account: account, inbox: inbox_dolce,
period_start: period_start, period_end: period_end,
conversations_count: 50, messages_count: 180)
end
it 'uses inbox name as unit_name in ranking' do
ranking = service.call[:unit_ranking]
expect(ranking.map { |u| u[:unit_name] }).to eq(['PRIME VL', 'DOLCE AMORE'])
end
it 'counts inboxes as units_analyzed' do
totals = service.call[:totals]
expect(totals[:units_analyzed]).to eq(2)
expect(totals[:insights_analyzed]).to eq(2)
end
it 'totals conversations from inbox insights when no global exists' do
totals = service.call[:totals]
expect(totals[:conversations]).to eq(250)
expect(totals[:messages]).to eq(980)
end
it 'computes AI performance per inbox' do
perf = service.call[:ai_performance]
expect(perf.map { |p| p[:unit_name] }).to contain_exactly('PRIME VL', 'DOLCE AMORE')
end
end
context 'with a global insight and per-inbox insights' do
before do
create(:captain_conversation_insight,
account: account, inbox: nil, captain_unit: nil,
period_start: period_start, period_end: period_end,
conversations_count: 500, messages_count: 2000)
create(:captain_conversation_insight,
account: account, inbox: inbox_prime,
period_start: period_start, period_end: period_end,
conversations_count: 200, messages_count: 800)
end
it 'uses the global insight for totals (no double counting)' do
totals = service.call[:totals]
expect(totals[:conversations]).to eq(500)
expect(totals[:messages]).to eq(2000)
end
it 'still ranks inboxes separately' do
ranking = service.call[:unit_ranking]
expect(ranking.size).to eq(1)
expect(ranking.first[:unit_name]).to eq('PRIME VL')
end
end
context 'with WoW delta per inbox' do
before do
create(:captain_conversation_insight,
account: account, inbox: inbox_prime,
period_start: period_start, period_end: period_end,
conversations_count: 120)
create(:captain_conversation_insight,
account: account, inbox: inbox_prime,
period_start: period_start - 7.days, period_end: period_end - 7.days,
conversations_count: 100)
end
it 'computes inbox-level WoW percentage delta' do
ranking = service.call[:unit_ranking]
expect(ranking.first[:conversations_delta_pct]).to eq(20.0)
end
end
context 'when no inbox insights exist (fallback to captain_unit)' do
let(:unit) { create(:captain_unit, account: account, name: 'Marca Prime') }
before do
create(:captain_conversation_insight,
account: account, captain_unit: unit,
period_start: period_start, period_end: period_end,
conversations_count: 80)
end
it 'uses captain_unit name when no inbox insights exist' do
ranking = service.call[:unit_ranking]
expect(ranking.first[:unit_name]).to eq('Marca Prime')
end
end
context 'when another account has insights' do
let(:other_account) { create(:account) }
before do
create(:captain_conversation_insight,
account: other_account, period_start: period_start, period_end: period_end,
conversations_count: 999)
end
it 'does not leak data from other accounts' do
expect(service.call[:totals][:conversations]).to eq(0)
end
end
end
end

View File

@ -0,0 +1,97 @@
require 'rails_helper'
RSpec.describe Captain::Reports::MattermostDeliveryService do
subject(:service) { described_class.new(digest: digest, webhook_url: webhook_url) }
let(:webhook_url) { 'https://mm.example.com/hooks/abc123' }
let(:digest) do
{
account_id: 1,
account_name: 'Grupo Nova',
period_start: Date.new(2026, 4, 9),
period_end: Date.new(2026, 4, 15),
totals: {
conversations: 250, messages: 980,
conversations_delta_pct: 12.5,
insights_analyzed: 2, units_analyzed: 2
},
unit_ranking: [
{ unit_id: 1, unit_name: 'Prime', conversations: 200, messages: 800, conversations_delta_pct: 10.0 }
],
ai_performance: [
{ unit_id: 1, unit_name: 'Prime', conversations: 200, failures_count: 3, success_rate_pct: 98.5,
top_failures: [{ 'description' => 'Erro X', 'frequency' => 2 }] }
],
satisfaction: {
most_dissatisfied: [{ unit_name: 'Prime', complaints_count: 2, negative_pct: 10.0, top_complaints: ['WiFi lento'], praises_count: 0,
top_praises: [] }],
most_satisfied: [{ unit_name: 'Prime', praises_count: 5, positive_pct: 70.0, top_praises: ['Staff ótimo'], complaints_count: 0,
top_complaints: [] }]
},
top_topics: [{ 'topic' => 'Check-in', 'count' => 10 }],
customer_opportunities: [{ 'opportunity' => 'Transfer do aeroporto', 'frequency' => 5 }],
faq_gaps: [{ 'question' => 'Estacionamento?', 'frequency' => 3 }],
complaints: [{ text: 'WiFi lento', frequency: 2 }],
praises: [{ text: 'Staff ótimo', frequency: 4 }],
most_requested_suites: [{ 'suite' => 'Presidencial', 'count' => 3 }],
recommendations: ['Revisar WiFi'],
period_summaries: []
}
end
describe '#call' do
context 'when webhook is missing' do
it 'raises ArgumentError' do
expect { described_class.new(digest: digest, webhook_url: '').call }.to raise_error(ArgumentError)
end
end
context 'when webhook responds 200' do
before do
stub_request(:post, webhook_url).to_return(status: 200, body: 'ok')
end
it 'returns success true' do
expect(service.call).to include(success: true, status: 200)
end
it 'posts a payload with attachments to the webhook' do
service.call
expect(WebMock).to(have_requested(:post, webhook_url).with do |req|
body = JSON.parse(req.body)
body['attachments'].is_a?(Array) && body['attachments'].any? { |a| a['title'].to_s.include?('Performance da IA') }
end)
end
it 'includes the customer opportunities block' do
service.call
expect(WebMock).to(have_requested(:post, webhook_url).with do |req|
body = JSON.parse(req.body)
body['attachments'].any? { |a| a['title'].to_s.include?('Oportunidades') }
end)
end
end
context 'when webhook fails' do
before do
stub_request(:post, webhook_url).to_return(status: 500, body: 'nope')
end
it 'returns success false with status' do
expect(service.call).to include(success: false, status: 500)
end
end
context 'when HTTP raises' do
before do
stub_request(:post, webhook_url).to_raise(StandardError.new('boom'))
end
it 'rescues and returns success false with error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to include('boom')
end
end
end
end

View File

@ -0,0 +1,54 @@
FactoryBot.define do
factory :captain_conversation_insight, class: 'Captain::ConversationInsight' do
association :account
period_start { Date.current - 7.days }
period_end { Date.current - 1.day }
status { 'done' }
conversations_count { 10 }
messages_count { 40 }
generated_at { Time.current }
payload do
{
'top_topics' => [{ 'topic' => 'Check-in', 'count' => 5, 'description' => 'Dúvidas sobre check-in' }],
'ai_failures' => [{ 'description' => 'Não soube responder sobre estacionamento', 'example' => 'Tem estacionamento?', 'frequency' => 3 }],
'faq_gaps' => [{ 'question' => 'Horário de check-out?', 'frequency' => 2 }],
'sentiment' => { 'positive_count' => 6, 'negative_count' => 2, 'neutral_count' => 2, 'summary' => 'Maioria satisfeita.' },
'highlights' => { 'praises' => ['Atendimento rápido'], 'complaints' => ['WiFi lento'] },
'most_requested_suites' => [{ 'suite' => 'Suíte Presidencial', 'count' => 3 }],
'price_reactions' => { 'summary' => 'Aceitação boa.', 'objections_count' => 1 },
'customer_opportunities' => [{ 'opportunity' => 'Transfer do aeroporto', 'frequency' => 2, 'example' => 'Vocês fazem transfer?' }],
'recommendations' => ['Criar FAQ sobre estacionamento'],
'period_summary' => 'Semana com 10 conversas e sentimento predominante positivo.'
}
end
trait :processing do
status { 'processing' }
payload { nil }
generated_at { nil }
end
trait :with_unit do
association :captain_unit, factory: :captain_unit
end
trait :with_inbox do
association :inbox, factory: :inbox
end
trait :empty do
conversations_count { 0 }
messages_count { 0 }
payload do
{
'top_topics' => [], 'ai_failures' => [], 'faq_gaps' => [],
'sentiment' => { 'positive_count' => 0, 'negative_count' => 0, 'neutral_count' => 0, 'summary' => '' },
'highlights' => { 'praises' => [], 'complaints' => [] },
'most_requested_suites' => [], 'price_reactions' => { 'summary' => '', 'objections_count' => 0 },
'customer_opportunities' => [], 'recommendations' => [], 'period_summary' => 'Sem dados.'
}
end
end
end
end