Compare commits
5 Commits
main
...
review/202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1adc79320a | ||
|
|
645ae4fec7 | ||
|
|
3d6e16f5f1 | ||
|
|
bf09e76eae | ||
|
|
6e7bcc9b44 |
@ -137,4 +137,3 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
<!-- Status: integração Mattermost ativa -->
|
||||
|
||||
@ -12,24 +12,14 @@ class V2::Reports::BotMetricsBuilder
|
||||
conversation_count: bot_conversations.count,
|
||||
message_count: bot_messages.count,
|
||||
resolution_rate: bot_resolution_rate.to_i,
|
||||
handoff_rate: total_handoff_rate.to_i,
|
||||
bot_resolutions_count: bot_resolutions_count,
|
||||
auto_handoffs_count: auto_handoffs_count,
|
||||
manual_takeovers_count: manual_takeovers_count
|
||||
handoff_rate: bot_handoff_rate.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_inbox_id
|
||||
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def bot_activated_inbox_ids
|
||||
@bot_activated_inbox_ids ||= begin
|
||||
ids = account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
filter_inbox_id ? ids & [filter_inbox_id] : ids
|
||||
end
|
||||
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
end
|
||||
|
||||
def bot_conversations
|
||||
@ -40,47 +30,14 @@ class V2::Reports::BotMetricsBuilder
|
||||
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
|
||||
end
|
||||
|
||||
def base_reporting_events
|
||||
scope = account.reporting_events.where(account_id: account.id, created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
|
||||
.select(:conversation_id)
|
||||
.where(name: :conversation_bot_resolved)
|
||||
.distinct.count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
# Auto handoff = Jasmine called bot_handoff! explicitly (loop, timeout, max_turns, intent)
|
||||
def auto_handoffs_count
|
||||
@auto_handoffs_count ||= base_reporting_events.joins(:conversation)
|
||||
.select(:conversation_id)
|
||||
.where(name: :conversation_bot_handoff)
|
||||
.distinct.count
|
||||
end
|
||||
|
||||
# Manual takeover = a human replied (via Chatwoot UI or WhatsApp echo) WITHOUT a bot_handoff
|
||||
# event being emitted for the same conversation. The bot itself uses sender_type 'Captain::Assistant',
|
||||
# so it's never counted here.
|
||||
def manual_takeovers_count
|
||||
@manual_takeovers_count ||= begin
|
||||
conv_ids_with_human_reply = bot_conversations
|
||||
.joins(:messages)
|
||||
.where(messages: { message_type: :outgoing })
|
||||
.where('messages.sender_type = ? OR messages.sender_type IS NULL', 'User')
|
||||
.distinct
|
||||
.pluck(:id)
|
||||
|
||||
conv_ids_with_auto_handoff = ReportingEvent.unscope(:order)
|
||||
.where(name: 'conversation_bot_handoff',
|
||||
conversation_id: conv_ids_with_human_reply)
|
||||
.distinct
|
||||
.pluck(:conversation_id)
|
||||
|
||||
(conv_ids_with_human_reply - conv_ids_with_auto_handoff).count
|
||||
end
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
@ -89,10 +46,9 @@ class V2::Reports::BotMetricsBuilder
|
||||
bot_resolutions_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
|
||||
# Total handoff = auto + manual (the gear that closes the math now)
|
||||
def total_handoff_rate
|
||||
def bot_handoff_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
|
||||
bot_handoffs_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
class V2::Reports::ConversionFunnelBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
# Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the
|
||||
# reception, card on arrival, etc. Anything that means the booking went through.
|
||||
PAID_STATUSES = %w[active completed confirmed].freeze
|
||||
|
||||
# Statuses we ignore from "created" (drafts are pre-save, never went live)
|
||||
IGNORED_CREATED_STATUSES = %w[draft].freeze
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def metrics
|
||||
{
|
||||
leads: leads_breakdown,
|
||||
reservations: reservations_breakdown,
|
||||
conversion_rates: conversion_rates_breakdown
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_inbox_id
|
||||
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def conversations_in_period
|
||||
@conversations_in_period ||= begin
|
||||
scope = account.conversations.where(created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope
|
||||
end
|
||||
end
|
||||
|
||||
def reservations_in_period
|
||||
@reservations_in_period ||= begin
|
||||
scope = account.captain_reservations.where(created_at: range)
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope.where.not(status: IGNORED_CREATED_STATUSES)
|
||||
end
|
||||
end
|
||||
|
||||
# Leads classified using the same logic as the "Novas × Retorno" tab:
|
||||
# new = no prior conversation in any inbox of the account
|
||||
# returning = had a prior conversation
|
||||
def leads_breakdown
|
||||
total = conversations_in_period.count
|
||||
return { total: 0, new: 0, returning: 0 } if total.zero?
|
||||
|
||||
conv_with_prior_ids = conversations_in_period
|
||||
.joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \
|
||||
'AND prev.account_id = conversations.account_id ' \
|
||||
'AND prev.id < conversations.id')
|
||||
.distinct
|
||||
.pluck(:id)
|
||||
|
||||
returning = conv_with_prior_ids.size
|
||||
{
|
||||
total: total,
|
||||
new: total - returning,
|
||||
returning: returning
|
||||
}
|
||||
end
|
||||
|
||||
def reservations_breakdown
|
||||
created = reservations_in_period.count
|
||||
paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||||
|
||||
{
|
||||
created: created,
|
||||
paid: paid
|
||||
}
|
||||
end
|
||||
|
||||
def conversion_rates_breakdown
|
||||
leads_total = conversations_in_period.count
|
||||
reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||||
reservations_created = reservations_in_period.count
|
||||
|
||||
{
|
||||
lead_to_paid_reservation: percent(reservations_paid, leads_total),
|
||||
lead_to_any_reservation: percent(reservations_created, leads_total),
|
||||
created_to_paid: percent(reservations_paid, reservations_created)
|
||||
}
|
||||
end
|
||||
|
||||
def percent(numerator, denominator)
|
||||
return 0 if denominator.to_i.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
end
|
||||
@ -1,79 +0,0 @@
|
||||
class V2::Reports::InboxBenchmarkingBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
PAID_STATUSES = V2::Reports::ConversionFunnelBuilder::PAID_STATUSES
|
||||
IGNORED_CREATED_STATUSES = V2::Reports::ConversionFunnelBuilder::IGNORED_CREATED_STATUSES
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
# Returns one row per inbox of the account, with leads + reservations + rate,
|
||||
# plus the brand name so the frontend can group by brand for benchmarking.
|
||||
def build
|
||||
return [] if range.blank?
|
||||
|
||||
inbox_brand_lookup = build_inbox_brand_lookup
|
||||
|
||||
account.inboxes.map do |inbox|
|
||||
brand_name = inbox_brand_lookup[inbox.id]
|
||||
|
||||
leads_total = leads_count_by_inbox[inbox.id] || 0
|
||||
reservations_created = reservations_created_by_inbox[inbox.id] || 0
|
||||
reservations_paid = reservations_paid_by_inbox[inbox.id] || 0
|
||||
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
brand_name: brand_name,
|
||||
leads_total: leads_total,
|
||||
reservations_created: reservations_created,
|
||||
reservations_paid: reservations_paid,
|
||||
conversion_rate: percent(reservations_paid, leads_total)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def leads_count_by_inbox
|
||||
@leads_count_by_inbox ||= account.conversations
|
||||
.where(created_at: range)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def reservations_created_by_inbox
|
||||
@reservations_created_by_inbox ||= account.captain_reservations
|
||||
.where(created_at: range)
|
||||
.where.not(status: IGNORED_CREATED_STATUSES)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def reservations_paid_by_inbox
|
||||
@reservations_paid_by_inbox ||= account.captain_reservations
|
||||
.where(created_at: range, status: PAID_STATUSES)
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
end
|
||||
|
||||
# inbox_id => brand_name (or nil when there is no brand mapped for this inbox)
|
||||
def build_inbox_brand_lookup
|
||||
rows = Captain::UnitInbox
|
||||
.joins(captain_unit: :brand)
|
||||
.where(inbox_id: account.inboxes.select(:id))
|
||||
.pluck('captain_unit_inboxes.inbox_id, captain_brands.name')
|
||||
|
||||
rows.to_h
|
||||
end
|
||||
|
||||
def percent(numerator, denominator)
|
||||
return 0 if denominator.to_i.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
end
|
||||
@ -1,111 +0,0 @@
|
||||
class V2::Reports::InboxLeadsSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
ALLOWED_GROUP_BY = %w[day week month].freeze
|
||||
RETURN_THRESHOLD = '24 hours'.freeze
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
return [] if range.blank? || inbox.blank?
|
||||
|
||||
rows = ActiveRecord::Base.connection.exec_query(
|
||||
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
|
||||
)
|
||||
|
||||
rows.map do |row|
|
||||
{
|
||||
period: row['period'].iso8601,
|
||||
new_leads: row['new_leads'].to_i,
|
||||
returning: row['returning'].to_i,
|
||||
others: row['others'].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
|
||||
end
|
||||
|
||||
def group_by
|
||||
value = params[:group_by].to_s
|
||||
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
|
||||
end
|
||||
|
||||
def sql_bindings
|
||||
{
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
since: range.begin,
|
||||
until_t: range.end,
|
||||
group_by: group_by,
|
||||
return_threshold: RETURN_THRESHOLD
|
||||
}
|
||||
end
|
||||
|
||||
# Single CTE to classify each conversation in the period as:
|
||||
# * new_leads: contact has no prior conversation in any inbox of the account
|
||||
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
|
||||
# event occurred more than 24h before the new conversation
|
||||
# * others: prior conversation existed but was not resolved or was resolved <24h ago
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def sql
|
||||
<<~SQL.squish
|
||||
WITH period_conversations AS (
|
||||
SELECT id, contact_id, created_at
|
||||
FROM conversations
|
||||
WHERE account_id = :account_id
|
||||
AND inbox_id = :inbox_id
|
||||
AND created_at >= :since
|
||||
AND created_at < :until_t
|
||||
),
|
||||
classified AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.created_at,
|
||||
EXISTS (
|
||||
SELECT 1 FROM conversations prev
|
||||
WHERE prev.contact_id = c.contact_id
|
||||
AND prev.account_id = :account_id
|
||||
AND prev.id < c.id
|
||||
) AS has_prior,
|
||||
(
|
||||
SELECT MAX(re.created_at)
|
||||
FROM reporting_events re
|
||||
INNER JOIN conversations prev ON prev.id = re.conversation_id
|
||||
WHERE re.name = 'conversation_resolved'
|
||||
AND prev.contact_id = c.contact_id
|
||||
AND prev.account_id = :account_id
|
||||
AND prev.id < c.id
|
||||
) AS latest_prior_resolution_at
|
||||
FROM period_conversations c
|
||||
)
|
||||
SELECT
|
||||
date_trunc(:group_by, created_at) AS period,
|
||||
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
|
||||
COUNT(*) FILTER (
|
||||
WHERE has_prior
|
||||
AND latest_prior_resolution_at IS NOT NULL
|
||||
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
|
||||
) AS returning,
|
||||
COUNT(*) FILTER (
|
||||
WHERE has_prior
|
||||
AND (
|
||||
latest_prior_resolution_at IS NULL
|
||||
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
|
||||
)
|
||||
) AS others
|
||||
FROM classified
|
||||
GROUP BY period
|
||||
ORDER BY period
|
||||
SQL
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
@ -23,9 +23,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
user_attrs = agent_params.slice(:name).to_h.compact.symbolize_keys
|
||||
user_attrs[:ui_settings] = merged_ui_settings if agent_params[:ui_settings].present?
|
||||
@agent.update!(user_attrs) if user_attrs.any?
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||
end
|
||||
|
||||
@ -74,19 +72,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_agent_params
|
||||
[:name, :email, :role, :availability, :auto_offline, { ui_settings: [:aggressive_alert_inbox_ids_mode, { aggressive_alert_inbox_ids: [] }] }]
|
||||
[:name, :email, :role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
params.require(:agent).permit(allowed_agent_params)
|
||||
end
|
||||
|
||||
def merged_ui_settings
|
||||
existing = @agent.ui_settings || {}
|
||||
incoming = agent_params[:ui_settings].to_h.deep_stringify_keys
|
||||
existing.merge(incoming)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
||||
def index
|
||||
insights = filtered_insights.order(period_start: :desc).limit(12)
|
||||
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
||||
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
|
||||
|
||||
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
|
||||
scope = scope.where(captain_unit_id: unit_id) if unit_id
|
||||
scope = scope.where(inbox_id: inbox_id) if inbox_id
|
||||
|
||||
insights = scope.order(period_start: :desc).limit(12)
|
||||
|
||||
render json: insights.map { |i| format_insight(i) }
|
||||
end
|
||||
@ -32,22 +39,6 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
||||
|
||||
private
|
||||
|
||||
def filtered_insights
|
||||
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
|
||||
scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id
|
||||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||||
scope = scope.for_period(*requested_period) if requested_period
|
||||
scope
|
||||
end
|
||||
|
||||
def filter_unit_id
|
||||
params[:unit_id].presence&.to_i
|
||||
end
|
||||
|
||||
def filter_inbox_id
|
||||
params[:inbox_id].presence&.to_i
|
||||
end
|
||||
|
||||
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
|
||||
insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end)
|
||||
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
|
||||
@ -86,14 +77,6 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
||||
default
|
||||
end
|
||||
|
||||
def requested_period
|
||||
return nil if params[:period_start].blank? || params[:period_end].blank?
|
||||
|
||||
[Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)]
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def format_insight(insight)
|
||||
{
|
||||
id: insight.id,
|
||||
|
||||
@ -96,8 +96,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||
:aggressive_alert_enabled]
|
||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
include Api::V2::Accounts::ReportsHelper
|
||||
include Api::V2::Accounts::HeatmapHelper
|
||||
@ -59,7 +58,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def bot_metrics
|
||||
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, bot_metrics_params).metrics
|
||||
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
@ -88,23 +87,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def inbox_leads_summary
|
||||
return head :unprocessable_entity if params[:inbox_id].blank?
|
||||
|
||||
builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def conversion_funnel
|
||||
builder = V2::Reports::ConversionFunnelBuilder.new(Current.account, conversion_funnel_params)
|
||||
render json: builder.metrics
|
||||
end
|
||||
|
||||
def inbox_benchmarking
|
||||
builder = V2::Reports::InboxBenchmarkingBuilder.new(Current.account, inbox_benchmarking_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@ -206,37 +188,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def bot_metrics_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_leads_summary_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
group_by: params[:group_by],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def conversion_funnel_params
|
||||
{
|
||||
inbox_id: params[:inbox_id],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_benchmarking_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
|
||||
#
|
||||
# Construtor responde async via POST pra esta URL com:
|
||||
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
|
||||
#
|
||||
# Este controller identifica a sessão do admin (por session_id no metadata
|
||||
# OU pelo cache key derivado de account_id que veio na query string) e
|
||||
# armazena a resposta no Rails.cache pra UI poder ler via polling.
|
||||
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
|
||||
def process_payload
|
||||
content = params[:content].to_s.strip
|
||||
return head :bad_request if content.blank?
|
||||
|
||||
session_key = resolve_session_key
|
||||
if session_key.blank?
|
||||
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
|
||||
return head :ok
|
||||
end
|
||||
|
||||
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
|
||||
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
|
||||
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
|
||||
head :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Hermes nao propaga chat_id no metadata da resposta de callback, entao
|
||||
# usamos a ultima sessao ativa do account (gravada por
|
||||
# HermesBuilder::Storage.remember_last_session no /start e /create).
|
||||
# MVP-safe pra 1 admin por vez por conta.
|
||||
def resolve_session_key
|
||||
account_id = params[:account_id]
|
||||
return nil if account_id.blank?
|
||||
|
||||
HermesBuilder::Storage.last_session_for(account_id)
|
||||
end
|
||||
end
|
||||
@ -1,226 +0,0 @@
|
||||
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
|
||||
#
|
||||
# Fluxo:
|
||||
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
|
||||
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
|
||||
# 2. Hermes processa via subscription Codex/etc dele.
|
||||
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
|
||||
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
|
||||
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
|
||||
# 4. Este controller cria a mensagem outgoing na conversation correta.
|
||||
#
|
||||
# Identificação da conversation: como o Hermes não preserva metadata customizado
|
||||
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
|
||||
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
|
||||
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
|
||||
class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
RECENT_WINDOW = 5.minutes
|
||||
|
||||
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
|
||||
# (quando o agente não sabe responder e quer escalar pra humano). NÃO
|
||||
# bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
|
||||
# próximas msgs não dispararem Hermes.
|
||||
HANDOFF_PATTERNS = [
|
||||
/\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
|
||||
/\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
|
||||
/\A\s*aguarde\s+um\s+instante/i
|
||||
].freeze
|
||||
|
||||
# Loop detection: 2 sinais combinados.
|
||||
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
|
||||
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
|
||||
# repetiu pergunta sobre o mesmo tópico.
|
||||
LOOP_SIMILARITY_THRESHOLD = 0.50
|
||||
LOOP_TOPIC_KEYWORD_OVERLAP = 3
|
||||
LOOP_STOPWORDS = %w[
|
||||
voce voces para por pra como mas isso esse essa estou esta este aqui ali
|
||||
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
|
||||
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
|
||||
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
|
||||
obrigado obrigada valeu vlw thanks por favor please
|
||||
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
|
||||
em com sem sob sobre antes apos depois entre meio tudo todo toda
|
||||
perfeito otimo certinho confirma confirme
|
||||
].freeze
|
||||
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
before_action :verify_signature
|
||||
before_action :fetch_inbox
|
||||
|
||||
def process_payload
|
||||
content = extract_content
|
||||
return head :bad_request if content.blank?
|
||||
|
||||
conversation = recent_conversation_for(@inbox)
|
||||
return log_no_conversation_and_ack if conversation.blank?
|
||||
|
||||
log_reply(conversation, content)
|
||||
detect_handoff_or_loop(conversation, content)
|
||||
deliver_outgoing(conversation, content)
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
head :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
|
||||
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
|
||||
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
|
||||
# pergunta reformulada) e escala.
|
||||
def detect_handoff_or_loop(conversation, content)
|
||||
if handoff_response?(content)
|
||||
mark_for_human_triage(conversation, reason: 'handoff_intencional')
|
||||
elsif looped_response?(conversation, content)
|
||||
mark_for_human_triage(conversation, reason: 'loop_detectado')
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_outgoing(conversation, content)
|
||||
if defined?(Captain::Hermes::DelayedReplyJob)
|
||||
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
|
||||
else
|
||||
create_outgoing_message(conversation, content)
|
||||
end
|
||||
end
|
||||
|
||||
def handoff_response?(content)
|
||||
return false if content.blank?
|
||||
|
||||
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
|
||||
end
|
||||
|
||||
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
|
||||
# outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
|
||||
# agente está repetindo pergunta/resposta sem progredir — geralmente
|
||||
# cliente fora do escopo (operadora telefonia, banco, suporte de outro
|
||||
# app, etc) OU fluxo travado.
|
||||
def looped_response?(conversation, content)
|
||||
prev = conversation.messages
|
||||
.where(message_type: :outgoing)
|
||||
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
|
||||
.reorder(created_at: :desc)
|
||||
.limit(1)
|
||||
.pick(:content)
|
||||
return false if prev.blank?
|
||||
|
||||
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
|
||||
|
||||
repeated_question?(content, prev)
|
||||
end
|
||||
|
||||
def similarity(text_a, text_b)
|
||||
set_a = tokenize(text_a)
|
||||
set_b = tokenize(text_b)
|
||||
return 0.0 if set_a.empty? || set_b.empty?
|
||||
|
||||
intersection = (set_a & set_b).size
|
||||
union = (set_a | set_b).size
|
||||
intersection.to_f / union
|
||||
end
|
||||
|
||||
# Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
|
||||
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
|
||||
def repeated_question?(text_a, text_b)
|
||||
return false unless inquisitive?(text_a) && inquisitive?(text_b)
|
||||
|
||||
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
|
||||
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
|
||||
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
|
||||
end
|
||||
|
||||
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
|
||||
|
||||
def inquisitive?(text)
|
||||
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
|
||||
end
|
||||
|
||||
def tokenize(text)
|
||||
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
|
||||
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
|
||||
end
|
||||
|
||||
def mark_for_human_triage(conversation, reason: nil)
|
||||
current = conversation.label_list
|
||||
conversation.update_labels((current + %w[triagem_humana]).uniq)
|
||||
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
|
||||
if inbox_id.present?
|
||||
@inbox = Inbox.find_by(id: inbox_id)
|
||||
elsif (slug = params[:slug].presence)
|
||||
# Resolve via slug (hermes_profile_name) — admin pode re-apontar a
|
||||
# inbox pra qualquer agente Hermes sem mexer em URL de callback.
|
||||
asst = Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
|
||||
ci = asst&.captain_inboxes&.first
|
||||
@inbox = ci&.inbox
|
||||
end
|
||||
head :not_found if @inbox.blank?
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
secret = Captain::Hermes.callback_signing_secret
|
||||
return true if secret.blank? # validação desabilitada (PoC sem secret)
|
||||
|
||||
signature = request.headers['X-Hermes-Callback-Signature'].to_s
|
||||
return head :unauthorized if signature.blank?
|
||||
|
||||
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def recent_conversation_for(inbox)
|
||||
inbox.conversations
|
||||
.where('updated_at >= ?', RECENT_WINDOW.ago)
|
||||
.where(status: %w[pending open])
|
||||
.reorder(updated_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def log_no_conversation_and_ack
|
||||
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
|
||||
head :ok
|
||||
end
|
||||
|
||||
def extract_content
|
||||
normalize_for_whatsapp(params[:content].to_s.strip)
|
||||
end
|
||||
|
||||
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
|
||||
# **negrito** -> *negrito*
|
||||
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
|
||||
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
|
||||
def normalize_for_whatsapp(content)
|
||||
return content if content.blank?
|
||||
|
||||
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
|
||||
end
|
||||
|
||||
def log_reply(conversation, content)
|
||||
Rails.logger.info(
|
||||
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
|
||||
)
|
||||
end
|
||||
|
||||
def create_outgoing_message(conversation, content)
|
||||
assistant = conversation.inbox.captain_assistant
|
||||
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
sender: sender,
|
||||
content: content,
|
||||
content_attributes: {
|
||||
external_source: 'hermes_callback'
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -1,110 +0,0 @@
|
||||
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
|
||||
#
|
||||
# POST /webhooks/captain/mcp
|
||||
#
|
||||
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
|
||||
# Captain (add_label, faq_lookup, generate_pix, etc).
|
||||
#
|
||||
# Conexão pelo Hermes:
|
||||
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
|
||||
#
|
||||
# Auth: aceita 2 modos (qualquer um basta):
|
||||
# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer <CAPTAIN_MCP_SECRET>`
|
||||
# É o que `hermes mcp add --auth header` usa nativamente.
|
||||
# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=<hex>`
|
||||
# Para clientes que preferem assinar o body inteiro.
|
||||
# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio,
|
||||
# validação é desabilitada (PoC/dev).
|
||||
#
|
||||
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
|
||||
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
|
||||
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
|
||||
# esse contexto pra resolver a conversa correta.
|
||||
class Webhooks::Captain::McpController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
before_action :verify_signature
|
||||
|
||||
def process_payload
|
||||
request_body = parse_request_body
|
||||
return head :bad_request if request_body.blank?
|
||||
|
||||
response = Captain::Mcp::Server.handle(
|
||||
request_body,
|
||||
context: extract_context(request_body)
|
||||
)
|
||||
|
||||
return head :ok if response.nil? # MCP notifications
|
||||
|
||||
render json: response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_request_body
|
||||
JSON.parse(request.raw_post)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
|
||||
return true if secret.blank?
|
||||
|
||||
return true if bearer_token_matches?(secret)
|
||||
return true if hmac_signature_matches?(secret)
|
||||
|
||||
head :unauthorized
|
||||
end
|
||||
|
||||
def bearer_token_matches?(secret)
|
||||
auth_header = request.headers['Authorization'].to_s
|
||||
return false unless auth_header.start_with?('Bearer ')
|
||||
|
||||
token = auth_header.delete_prefix('Bearer ').strip
|
||||
ActiveSupport::SecurityUtils.secure_compare(token, secret)
|
||||
end
|
||||
|
||||
def hmac_signature_matches?(secret)
|
||||
signature = request.headers['X-Hub-Signature-256'].to_s
|
||||
return false if signature.blank?
|
||||
|
||||
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
||||
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
||||
end
|
||||
|
||||
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
|
||||
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
|
||||
# é (já que MCP em si é stateless entre client/server).
|
||||
#
|
||||
# Fallback: cada profile do Hermes está atrelado a uma unidade
|
||||
# (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos
|
||||
# contexto via headers HTTP fixos no config.yaml do profile:
|
||||
# X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id.
|
||||
# Body wins se houver conflito (override por chamada).
|
||||
def extract_context(request_body)
|
||||
params = request_body['params'] || {}
|
||||
body_ctx = params['_captain_context'] || {}
|
||||
body_ctx = {} unless body_ctx.is_a?(Hash)
|
||||
|
||||
extract_header_context.merge(body_ctx.symbolize_keys)
|
||||
end
|
||||
|
||||
def extract_header_context
|
||||
{
|
||||
account_id: header_int('X-Captain-Account-Id'),
|
||||
assistant_id: header_int('X-Captain-Assistant-Id'),
|
||||
inbox_id: header_int('X-Captain-Inbox-Id')
|
||||
}.compact
|
||||
end
|
||||
|
||||
def header_int(name)
|
||||
value = request.headers[name].to_s
|
||||
return nil if value.blank?
|
||||
|
||||
value.to_i
|
||||
end
|
||||
end
|
||||
@ -5,7 +5,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import AggressiveConversationBanner from './components/app/AggressiveConversationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@ -31,7 +30,6 @@ export default {
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
AggressiveConversationBanner,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
@ -136,7 +134,6 @@ export default {
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<AggressiveConversationBanner />
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class HermesBuilder extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/hermes_builder', { accountScoped: true });
|
||||
}
|
||||
|
||||
fetchMessages() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
sendMessage(text) {
|
||||
return axios.post(this.url, { text });
|
||||
}
|
||||
|
||||
start() {
|
||||
return axios.post(`${this.url}/start`);
|
||||
}
|
||||
|
||||
reset() {
|
||||
return axios.delete(`${this.url}/reset`);
|
||||
}
|
||||
|
||||
fetchAssistants() {
|
||||
return axios.get(`${this.url}/assistants`);
|
||||
}
|
||||
|
||||
validate(slug) {
|
||||
return axios.get(`${this.url}/validate`, { params: { slug } });
|
||||
}
|
||||
|
||||
repair(slug, repairId) {
|
||||
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
|
||||
}
|
||||
}
|
||||
|
||||
export default new HermesBuilder();
|
||||
@ -0,0 +1,29 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class NotificationTemplatesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAll(inboxId) {
|
||||
return this.get(`${inboxId}/notification_templates`);
|
||||
}
|
||||
|
||||
create(inboxId, data) {
|
||||
return this.post(`${inboxId}/notification_templates`, {
|
||||
notification_template: data,
|
||||
});
|
||||
}
|
||||
|
||||
update(inboxId, id, data) {
|
||||
return this.patch(`${inboxId}/notification_templates/${id}`, {
|
||||
notification_template: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete(inboxId, id) {
|
||||
return this.delete(`${inboxId}/notification_templates/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotificationTemplatesAPI();
|
||||
@ -91,57 +91,23 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getBotMetrics({ from, to, inboxId } = {}) {
|
||||
getBotMetrics({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/bot_metrics`, {
|
||||
params: { since: from, until: to, inbox_id: inboxId },
|
||||
params: { since: from, until: to },
|
||||
});
|
||||
}
|
||||
|
||||
getBotSummary({ from, to, groupBy, businessHours, type, id } = {}) {
|
||||
getBotSummary({ from, to, groupBy, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/bot_summary`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
type: type || 'account',
|
||||
id,
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) {
|
||||
return axios.get(`${this.url}/inbox_leads_summary`, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
since: from,
|
||||
until: to,
|
||||
group_by: groupBy,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getConversionFunnel({ inboxId, from, to } = {}) {
|
||||
return axios.get(`${this.url}/conversion_funnel`, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
since: from,
|
||||
until: to,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getInboxBenchmarking({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/inbox_benchmarking`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
||||
@ -26,10 +26,6 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
engine: {
|
||||
type: String,
|
||||
default: 'captain_interno',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
@ -80,27 +76,11 @@ const handleAction = ({ action, value }) => {
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</h6>
|
||||
<span
|
||||
v-if="engine === 'hermes'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||
</span>
|
||||
</div>
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</h6>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
|
||||
@ -10,7 +10,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@ -72,7 +71,6 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
@ -144,7 +142,7 @@ const handleDocumentableClick = () => {
|
||||
<div v-if="!compact && showMenu" class="flex items-center gap-2">
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="responseManagePermissions"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@ -170,7 +168,7 @@ const handleDocumentableClick = () => {
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="responseManagePermissions">
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
|
||||
@ -6,7 +6,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
@ -29,7 +28,6 @@ const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@ -58,7 +56,7 @@ const onClearFilters = () => {
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="responseManagePermissions"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, maxLength } from '@vuelidate/validators';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@ -39,17 +39,11 @@ const initialState = {
|
||||
assistant_id: '',
|
||||
};
|
||||
|
||||
const QUESTION_MAX_LENGTH = 255;
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const validationRules = computed(() => {
|
||||
const rules = {
|
||||
question: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
maxLength: maxLength(QUESTION_MAX_LENGTH),
|
||||
},
|
||||
question: { required, minLength: minLength(1) },
|
||||
answer: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
@ -129,7 +123,6 @@ watch(
|
||||
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
|
||||
:message="formErrors.question"
|
||||
:message-type="formErrors.question ? 'error' : 'info'"
|
||||
:maxlength="QUESTION_MAX_LENGTH"
|
||||
/>
|
||||
<Editor
|
||||
v-model="state.answer"
|
||||
|
||||
@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createAssistant']);
|
||||
|
||||
@ -106,16 +105,14 @@ const openCreateAssistantDialog = () => {
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
||||
</p>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</Policy>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
@ -133,20 +130,6 @@ const openCreateAssistantDialog = () => {
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ assistant.name || '' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="assistant.engine === 'hermes'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
|
||||
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||
</span>
|
||||
<Avatar
|
||||
v-if="assistant"
|
||||
:name="assistant.name"
|
||||
|
||||
@ -424,12 +424,6 @@ const menuItems = computed(() => {
|
||||
activeOn: ['captain_roleta_index'],
|
||||
to: accountScopedRoute('captain_roleta_index'),
|
||||
},
|
||||
{
|
||||
name: 'HermesBuilder',
|
||||
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
|
||||
activeOn: ['captain_hermes_builder_index'],
|
||||
to: accountScopedRoute('captain_hermes_builder_index'),
|
||||
},
|
||||
{
|
||||
name: 'Funnel',
|
||||
label: t('SIDEBAR.CAPTAIN_FUNNEL'),
|
||||
@ -442,6 +436,12 @@ const menuItems = computed(() => {
|
||||
activeOn: ['captain_settings_reports'],
|
||||
to: accountScopedRoute('captain_settings_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
label: t('SIDEBAR.CAPTAIN_NOTIFICATIONS'),
|
||||
activeOn: ['captain_settings_notifications'],
|
||||
to: accountScopedRoute('captain_settings_notifications'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -556,11 +556,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Directory Dashboard',
|
||||
label: t('SIDEBAR.REPORTS_DIRECTORY_DASHBOARD'),
|
||||
to: accountScopedRoute('directory_dashboard_reports'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,340 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import aggressiveAlert from 'dashboard/helper/aggressiveAlert';
|
||||
import inactivityAlertTracker from 'dashboard/helper/inactivityAlertTracker';
|
||||
|
||||
export default {
|
||||
name: 'AggressiveConversationBanner',
|
||||
data() {
|
||||
return {
|
||||
alerts: [],
|
||||
maxLevel: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
allConversations: 'getAllConversations',
|
||||
currentUser: 'getCurrentUser',
|
||||
}),
|
||||
allowedInboxIds() {
|
||||
// null → sem filtro (todas); array → só essas.
|
||||
const raw =
|
||||
this.currentUser &&
|
||||
this.currentUser.ui_settings &&
|
||||
this.currentUser.ui_settings.aggressive_alert_inbox_ids;
|
||||
if (raw == null) return null;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
return raw.map(id => Number(id));
|
||||
},
|
||||
hasAlerts() {
|
||||
return this.alerts.length > 0;
|
||||
},
|
||||
bannerClass() {
|
||||
return [
|
||||
'aggressive-banner',
|
||||
this.maxLevel ? `aggressive-banner--${this.maxLevel}` : '',
|
||||
];
|
||||
},
|
||||
bannerHeadline() {
|
||||
const count = this.alerts.length;
|
||||
if (count === 1) {
|
||||
const a = this.alerts[0];
|
||||
if (a.kind === 'reopened') {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_REOPENED',
|
||||
'Conversa reaberta — responda agora'
|
||||
);
|
||||
}
|
||||
// inactivity — mostra tempo
|
||||
if (a.minutes >= 28) {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_28',
|
||||
{ minutes: a.minutes },
|
||||
`🚨 ${a.minutes} MIN SEM RESPOSTA — conversa fecha em breve`
|
||||
);
|
||||
}
|
||||
if (a.minutes >= 15) {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_15',
|
||||
{ minutes: a.minutes },
|
||||
`⚠️ ${a.minutes} MIN SEM RESPOSTA`
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_5',
|
||||
{ minutes: a.minutes },
|
||||
`⏰ ${a.minutes} min sem resposta`
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_MULTIPLE',
|
||||
{ count },
|
||||
`🚨 ${count} conversas aguardando resposta`
|
||||
);
|
||||
},
|
||||
explanation() {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.EXPLANATION',
|
||||
'Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente.'
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Rehidrata o tracker de inatividade toda vez que a lista de conversas
|
||||
// muda (inclusive no boot). Dessa forma, conversas que já estão em
|
||||
// 'open' com o cliente esperando resposta entram no tracker mesmo
|
||||
// quando o usuário só abriu a aba sem receber mensagem ao vivo.
|
||||
allConversations: {
|
||||
handler(conversations) {
|
||||
const allowed = this.allowedInboxIds;
|
||||
const filtered =
|
||||
allowed === null
|
||||
? conversations
|
||||
: (conversations || []).filter(c =>
|
||||
allowed.includes(Number(c && c.inbox_id))
|
||||
);
|
||||
inactivityAlertTracker.hydrateFromConversations(filtered);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
|
||||
// Rehidrata se alertas foram disparados antes do componente montar
|
||||
this.refreshAlerts();
|
||||
},
|
||||
beforeUnmount() {
|
||||
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
|
||||
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
|
||||
},
|
||||
methods: {
|
||||
refreshAlerts() {
|
||||
this.alerts = aggressiveAlert.getActiveConversations();
|
||||
this.maxLevel = aggressiveAlert.getMaxLevel();
|
||||
},
|
||||
openConversation(alert) {
|
||||
// Clica no item → abre conversa E esconde o alerta dela (mas se
|
||||
// não responder, volta a aparecer no próximo threshold).
|
||||
// Param tem que ser `conversation_id` (snake_case, como
|
||||
// declarado no path da rota); camelCase faz Vue Router não casar
|
||||
// e cair em "selecione uma conversa".
|
||||
aggressiveAlert.dismiss(alert.id);
|
||||
if (!this.currentAccountId) return;
|
||||
this.$router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: this.currentAccountId,
|
||||
conversation_id: alert.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
dismissOne(alert) {
|
||||
aggressiveAlert.dismiss(alert.id);
|
||||
},
|
||||
alertItemClass(alert) {
|
||||
return [
|
||||
'aggressive-banner__item',
|
||||
`aggressive-banner__item--${alert.level}`,
|
||||
];
|
||||
},
|
||||
alertContextLabel(alert) {
|
||||
if (alert.kind === 'reopened') {
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.KIND_REOPENED',
|
||||
'reabriu'
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.KIND_WAITING',
|
||||
{ minutes: alert.minutes || '?' },
|
||||
`${alert.minutes || '?'} min sem resposta`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasAlerts" :class="bannerClass" role="alert" aria-live="assertive">
|
||||
<div class="aggressive-banner__headline">
|
||||
{{ bannerHeadline }}
|
||||
</div>
|
||||
<div class="aggressive-banner__explanation">
|
||||
{{ explanation }}
|
||||
</div>
|
||||
<ul class="aggressive-banner__list">
|
||||
<li
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
:class="alertItemClass(alert)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__open"
|
||||
@click="openConversation(alert)"
|
||||
>
|
||||
<span class="aggressive-banner__contact">{{
|
||||
alert.contactName || '—'
|
||||
}}</span>
|
||||
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
|
||||
· {{ alert.inboxName }}
|
||||
</span>
|
||||
<span class="aggressive-banner__context">
|
||||
· {{ alertContextLabel(alert) }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="aggressive-banner__close"
|
||||
:aria-label="
|
||||
$t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE', 'Esconder')
|
||||
"
|
||||
:title="
|
||||
$t(
|
||||
'AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE_TITLE',
|
||||
'Esconde temporariamente — volta se não responder'
|
||||
)
|
||||
"
|
||||
@click="dismissOne(alert)"
|
||||
>
|
||||
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ICON', '×') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes aggressive-pulse-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #eab308;
|
||||
}
|
||||
50% {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-orange {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #c2410c;
|
||||
}
|
||||
50% {
|
||||
background-color: #f97316;
|
||||
}
|
||||
}
|
||||
@keyframes aggressive-pulse-red {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #991b1b;
|
||||
}
|
||||
50% {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.aggressive-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
color: #ffffff;
|
||||
padding: 14px 20px;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aggressive-banner--yellow {
|
||||
background-color: #eab308;
|
||||
color: #1f2937;
|
||||
}
|
||||
.aggressive-banner--orange {
|
||||
background-color: #c2410c;
|
||||
animation: aggressive-pulse-orange 1.4s ease-in-out infinite;
|
||||
}
|
||||
.aggressive-banner--red {
|
||||
background-color: #991b1b;
|
||||
animation: aggressive-pulse-red 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.aggressive-banner__headline {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.aggressive-banner__explanation {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
opacity: 0.92;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aggressive-banner__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aggressive-banner__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aggressive-banner__item--yellow {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.aggressive-banner__open {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aggressive-banner__open:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.aggressive-banner__contact {
|
||||
font-weight: 800;
|
||||
}
|
||||
.aggressive-banner__inbox,
|
||||
.aggressive-banner__context {
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aggressive-banner__close {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
.aggressive-banner__close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@ -1,8 +1,6 @@
|
||||
import AuthAPI from '../api/auth';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import aggressiveAlert from './aggressiveAlert';
|
||||
import inactivityAlertTracker from './inactivityAlertTracker';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
@ -109,127 +107,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
lastActivityAt,
|
||||
conversationId,
|
||||
});
|
||||
this.feedInactivityTracker(data);
|
||||
};
|
||||
|
||||
// Alimenta o tracker de inatividade:
|
||||
// - Cliente (Contact) mandou mensagem em conversa open → começa a contar
|
||||
// - Agente (User/AgentBot/Captain) mandou mensagem → limpa (agente respondeu)
|
||||
// - Status deixou de ser open → trata como "resolvido", limpa
|
||||
feedInactivityTracker = data => {
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
const {
|
||||
conversation_id: conversationId,
|
||||
message_type: messageType,
|
||||
sender_type: senderType,
|
||||
conversation,
|
||||
} = data;
|
||||
|
||||
// message_type: 0=incoming, 1=outgoing, 2=activity, 3=template
|
||||
// Activity = evento do sistema (status mudou, etc). Ignora.
|
||||
if (messageType === 2 || messageType === 'activity') return;
|
||||
|
||||
// Incoming (cliente) e conversa aberta → começa/renova tracker
|
||||
const isIncoming = messageType === 0 || messageType === 'incoming';
|
||||
const conversationStatus = conversation && conversation.status;
|
||||
if (isIncoming && conversationStatus === 'open') {
|
||||
const inboxId = conversation && conversation.inbox_id;
|
||||
if (!this.isInboxAllowedForUser(inboxId)) return;
|
||||
const contactName =
|
||||
conversation && conversation.meta && conversation.meta.sender
|
||||
? conversation.meta.sender.name
|
||||
: '';
|
||||
const inbox = this.app.$store.getters['inboxes/getInbox']
|
||||
? this.app.$store.getters['inboxes/getInbox'](inboxId)
|
||||
: null;
|
||||
const inboxName = inbox && inbox.name ? inbox.name : '';
|
||||
inactivityAlertTracker.onClientMessage({
|
||||
conversationId,
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Qualquer mensagem do agente/bot → limpa tracker
|
||||
if (senderType === 'User' || senderType === 'AgentBot') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
// Lê account.settings.aggressive_alert_enabled + user.ui_settings
|
||||
isAggressiveAlertEnabled = () => {
|
||||
const store = this.app.$store;
|
||||
const account = store.getters.getCurrentAccount;
|
||||
const user = store.getters.getCurrentUser;
|
||||
|
||||
// Default true se settings não vieram ainda (não bloqueia no boot).
|
||||
const accountEnabled =
|
||||
!account ||
|
||||
!account.settings ||
|
||||
account.settings.aggressive_alert_enabled !== false;
|
||||
const userEnabled =
|
||||
!user ||
|
||||
!user.ui_settings ||
|
||||
user.ui_settings.aggressive_alert_enabled !== false;
|
||||
|
||||
return accountEnabled && userEnabled;
|
||||
};
|
||||
|
||||
// Filtra alertas por inbox conforme a preferência do user.
|
||||
// ui_settings.aggressive_alert_inbox_ids:
|
||||
// - null/undefined → todas as inboxes (default, legado)
|
||||
// - [] (vazio) → nenhuma inbox (silenciou tudo)
|
||||
// - [1, 2, 3] → só essas inboxes
|
||||
isInboxAllowedForUser = inboxId => {
|
||||
if (inboxId == null) return true;
|
||||
const user = this.app.$store.getters.getCurrentUser;
|
||||
const allowed =
|
||||
user && user.ui_settings && user.ui_settings.aggressive_alert_inbox_ids;
|
||||
if (allowed == null) return true;
|
||||
if (!Array.isArray(allowed)) return true;
|
||||
// Inbox ids podem vir como number no evento e string no ui_settings.
|
||||
return allowed.some(id => Number(id) === Number(inboxId));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReload = () => window.location.reload();
|
||||
|
||||
onStatusChange = data => {
|
||||
this.maybeTriggerAggressiveAlert(data);
|
||||
// Se saiu de 'open' (resolvida/snoozada/pending), limpa qualquer alerta
|
||||
// pendente pra essa conversa.
|
||||
if (data && data.id && data.status && data.status !== 'open') {
|
||||
inactivityAlertTracker.onAgentReplyOrResolved(data.id);
|
||||
}
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
// Dispara banner RED toda vez que a conversa transita pra 'open'.
|
||||
// Broadcast `conversation.status_changed` só chega em mudança real,
|
||||
// então confiar no evento é suficiente.
|
||||
maybeTriggerAggressiveAlert = data => {
|
||||
if (!data || data.status !== 'open') return;
|
||||
if (!this.isAggressiveAlertEnabled()) return;
|
||||
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
|
||||
const store = this.app.$store;
|
||||
const contactName =
|
||||
data.meta && data.meta.sender ? data.meta.sender.name : '';
|
||||
const inbox = store.getters['inboxes/getInbox']
|
||||
? store.getters['inboxes/getInbox'](data.inbox_id)
|
||||
: null;
|
||||
const inboxName = inbox && inbox.name ? inbox.name : '';
|
||||
|
||||
aggressiveAlert.trigger({
|
||||
conversationId: data.id,
|
||||
level: 'red',
|
||||
kind: 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
};
|
||||
|
||||
onConversationUpdated = data => {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
this.fetchConversationStats();
|
||||
|
||||
@ -1,303 +0,0 @@
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const ALERT_AUDIO_PATH = '/audio/dashboard/bell.mp3';
|
||||
const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
|
||||
const TITLE_FLASH_INTERVAL_MS = 1000;
|
||||
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
|
||||
|
||||
// Níveis de severidade — ordem numérica cresce com a urgência.
|
||||
export const LEVEL = {
|
||||
YELLOW: 'yellow',
|
||||
ORANGE: 'orange',
|
||||
RED: 'red',
|
||||
};
|
||||
|
||||
const LEVEL_SEVERITY = {
|
||||
[LEVEL.YELLOW]: 1,
|
||||
[LEVEL.ORANGE]: 2,
|
||||
[LEVEL.RED]: 3,
|
||||
};
|
||||
|
||||
const showOSNotification = (title, body) => {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new Notification(title, {
|
||||
body,
|
||||
tag: NOTIFICATION_TAG,
|
||||
requireInteraction: true,
|
||||
renotify: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// Safari iOS lança TypeError no construtor; banner visual + som cobrem.
|
||||
}
|
||||
};
|
||||
|
||||
const vibrateDevice = () => {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.vibrate === 'function'
|
||||
) {
|
||||
navigator.vibrate(VIBRATION_PATTERN);
|
||||
}
|
||||
};
|
||||
|
||||
class AggressiveAlertManager {
|
||||
constructor() {
|
||||
this.audio = null;
|
||||
this.titleInterval = null;
|
||||
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
|
||||
// Map<conversationId, { level, kind, contactName, inboxName, minutes, triggeredAt, temporarilyHidden }>
|
||||
this.activeConversations = new Map();
|
||||
}
|
||||
|
||||
ensureAudio() {
|
||||
if (this.audio) return;
|
||||
this.audio = new Audio(ALERT_AUDIO_PATH);
|
||||
}
|
||||
|
||||
// Som em loop infinito (usado pro nível RED — urgência máxima)
|
||||
playLoopSound() {
|
||||
this.ensureAudio();
|
||||
this.audio.loop = true;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {
|
||||
// Autoplay bloqueado pelo browser — banner visual permanece.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Som 1x (usado pro ORANGE — chama atenção mas não satura)
|
||||
playOnceSound() {
|
||||
// Se já está tocando em loop pra outro alerta, não interfere.
|
||||
if (this.hasLoopSound()) return;
|
||||
this.ensureAudio();
|
||||
this.audio.loop = false;
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
hasLoopSound() {
|
||||
// Loop está ativo se algum alerta no map tem level === RED e não está hidden.
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry => entry.level === LEVEL.RED && !entry.temporarilyHidden
|
||||
);
|
||||
}
|
||||
|
||||
stopSound() {
|
||||
if (!this.audio) return;
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.loop = false;
|
||||
}
|
||||
|
||||
// O título pisca se existir pelo menos 1 alerta visível com level ORANGE ou RED.
|
||||
shouldFlashTitle() {
|
||||
return Array.from(this.activeConversations.values()).some(
|
||||
entry =>
|
||||
!entry.temporarilyHidden &&
|
||||
(entry.level === LEVEL.ORANGE || entry.level === LEVEL.RED)
|
||||
);
|
||||
}
|
||||
|
||||
countVisibleAlerts() {
|
||||
return Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
).length;
|
||||
}
|
||||
|
||||
updateTitleTick(toggle) {
|
||||
if (!this.shouldFlashTitle()) {
|
||||
document.title = this.originalTitle;
|
||||
return;
|
||||
}
|
||||
const count = this.countVisibleAlerts();
|
||||
document.title = toggle
|
||||
? `🚨 (${count}) CONVERSA ABERTA`
|
||||
: this.originalTitle;
|
||||
}
|
||||
|
||||
startTitleFlash() {
|
||||
if (this.titleInterval) return;
|
||||
if (!this.shouldFlashTitle()) return;
|
||||
let toggle = false;
|
||||
this.updateTitleTick(true);
|
||||
this.titleInterval = setInterval(() => {
|
||||
toggle = !toggle;
|
||||
this.updateTitleTick(toggle);
|
||||
}, TITLE_FLASH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stopTitleFlash() {
|
||||
if (this.titleInterval) {
|
||||
clearInterval(this.titleInterval);
|
||||
this.titleInterval = null;
|
||||
}
|
||||
document.title = this.originalTitle;
|
||||
}
|
||||
|
||||
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
|
||||
refreshOutputs() {
|
||||
const hasLoop = this.hasLoopSound();
|
||||
const shouldFlash = this.shouldFlashTitle();
|
||||
|
||||
if (hasLoop) {
|
||||
this.playLoopSound();
|
||||
} else {
|
||||
this.stopSound();
|
||||
}
|
||||
|
||||
if (shouldFlash) {
|
||||
this.startTitleFlash();
|
||||
} else {
|
||||
this.stopTitleFlash();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara ou escala um alerta.
|
||||
* @param {Object} opts
|
||||
* @param {number|string} opts.conversationId
|
||||
* @param {string} opts.level - LEVEL.YELLOW | LEVEL.ORANGE | LEVEL.RED
|
||||
* @param {string} opts.kind - 'reopened' | 'inactivity'
|
||||
* @param {string} [opts.contactName]
|
||||
* @param {string} [opts.inboxName]
|
||||
* @param {number} [opts.minutes] - só pra inactivity (5/15/28)
|
||||
*/
|
||||
trigger({
|
||||
conversationId,
|
||||
level = LEVEL.RED,
|
||||
kind = 'reopened',
|
||||
contactName,
|
||||
inboxName,
|
||||
minutes,
|
||||
}) {
|
||||
if (!conversationId) return;
|
||||
const existing = this.activeConversations.get(conversationId);
|
||||
|
||||
// Escalada: se já existe e o novo level é MENOS severo, ignora.
|
||||
// Se for mais severo, atualiza (ex: yellow → orange, inactivity).
|
||||
if (existing) {
|
||||
const currentSev = LEVEL_SEVERITY[existing.level] || 0;
|
||||
const incomingSev = LEVEL_SEVERITY[level] || 0;
|
||||
// Se o alerta tá "escondido temporariamente" e chegou novo, desesconde.
|
||||
if (incomingSev >= currentSev || existing.temporarilyHidden) {
|
||||
this.activeConversations.set(conversationId, {
|
||||
...existing,
|
||||
level: incomingSev > currentSev ? level : existing.level,
|
||||
kind: incomingSev > currentSev ? kind : existing.kind,
|
||||
minutes: incomingSev > currentSev ? minutes : existing.minutes,
|
||||
contactName: contactName || existing.contactName,
|
||||
inboxName: inboxName || existing.inboxName,
|
||||
temporarilyHidden: false,
|
||||
triggeredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.activeConversations.set(conversationId, {
|
||||
level,
|
||||
kind,
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
minutes: minutes || null,
|
||||
triggeredAt: Date.now(),
|
||||
temporarilyHidden: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Som por nível
|
||||
if (level === LEVEL.RED) {
|
||||
this.playLoopSound();
|
||||
} else if (level === LEVEL.ORANGE) {
|
||||
this.playOnceSound();
|
||||
}
|
||||
// YELLOW: sem som
|
||||
|
||||
if (level === LEVEL.ORANGE || level === LEVEL.RED) {
|
||||
showOSNotification(
|
||||
'🚨 Conversa aguardando resposta',
|
||||
`${contactName || 'Cliente'} — ${inboxName || ''}`.trim()
|
||||
);
|
||||
vibrateDevice();
|
||||
}
|
||||
|
||||
this.startTitleFlash();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* × — dismiss temporário. Remove do visual mas mantém no map como "hidden".
|
||||
* Volta a aparecer se escalar (receber mais severo) ou receber nova mensagem.
|
||||
* Pra limpar de verdade, o agente tem que responder (então o tracker chama
|
||||
* dismissForReply).
|
||||
*/
|
||||
hide(conversationId) {
|
||||
const entry = this.activeConversations.get(conversationId);
|
||||
if (!entry) return;
|
||||
this.activeConversations.set(conversationId, {
|
||||
...entry,
|
||||
temporarilyHidden: true,
|
||||
});
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss definitivo — chamado quando o agente respondeu ou o tracker
|
||||
* detectou que o cliente não é mais o último a mandar.
|
||||
*/
|
||||
dismissForReply(conversationId) {
|
||||
if (!this.activeConversations.has(conversationId)) return;
|
||||
this.activeConversations.delete(conversationId);
|
||||
this.refreshOutputs();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
|
||||
}
|
||||
|
||||
// Mesmo que hide, mas pra API pública (botão × do banner)
|
||||
dismiss(conversationId) {
|
||||
this.hide(conversationId);
|
||||
}
|
||||
|
||||
dismissAll() {
|
||||
if (this.activeConversations.size === 0) return;
|
||||
this.activeConversations.clear();
|
||||
this.stopSound();
|
||||
this.stopTitleFlash();
|
||||
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, null);
|
||||
}
|
||||
|
||||
emitBusEvent(event, conversationId) {
|
||||
emitter.emit(event, {
|
||||
conversationId,
|
||||
total: this.countVisibleAlerts(),
|
||||
});
|
||||
}
|
||||
|
||||
getActiveConversations() {
|
||||
return Array.from(this.activeConversations.entries())
|
||||
.filter(([, data]) => !data.temporarilyHidden)
|
||||
.map(([id, data]) => ({ id, ...data }));
|
||||
}
|
||||
|
||||
// Level mais alto entre os alertas visíveis — o banner usa pra cor do wrapper.
|
||||
getMaxLevel() {
|
||||
const visible = Array.from(this.activeConversations.values()).filter(
|
||||
entry => !entry.temporarilyHidden
|
||||
);
|
||||
if (visible.length === 0) return null;
|
||||
return visible.reduce((winner, entry) => {
|
||||
const sevWinner = LEVEL_SEVERITY[winner] || 0;
|
||||
const sevEntry = LEVEL_SEVERITY[entry.level] || 0;
|
||||
return sevEntry > sevWinner ? entry.level : winner;
|
||||
}, null);
|
||||
}
|
||||
}
|
||||
|
||||
const aggressiveAlert = new AggressiveAlertManager();
|
||||
|
||||
export default aggressiveAlert;
|
||||
@ -1,245 +0,0 @@
|
||||
import aggressiveAlert, { LEVEL } from './aggressiveAlert';
|
||||
|
||||
// Thresholds de inatividade. Cada um dispara UMA vez por conversa (enquanto
|
||||
// o cliente segue sendo o último a falar). Ordem: do menos urgente ao mais.
|
||||
const THRESHOLDS = [
|
||||
{ minutes: 5, level: LEVEL.YELLOW },
|
||||
{ minutes: 15, level: LEVEL.ORANGE },
|
||||
{ minutes: 28, level: LEVEL.RED },
|
||||
];
|
||||
|
||||
// Checa o estado dos alertas a cada 20s — granularidade suficiente pra
|
||||
// não perder threshold (a menor janela entre thresholds é 5min = 300s).
|
||||
const CHECK_INTERVAL_MS = 20_000;
|
||||
|
||||
// Logs opt-in. Ativar no DevTools: window.__AGGRESSIVE_DEBUG__ = true
|
||||
// Serve pra investigar porque o banner de inatividade não dispara numa
|
||||
// conversa específica sem ter que tornar logs permanentes em prod.
|
||||
const debug = (...args) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window.__AGGRESSIVE_DEBUG__
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[aggressive-alert]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
function findLastNonActivityMessage(conv) {
|
||||
// 1) Preferir o campo dedicado do payload da listagem — o serializer
|
||||
// já filtra atividades (`non_activity_messages`) antes de setar aqui.
|
||||
if (conv.last_non_activity_message) return conv.last_non_activity_message;
|
||||
// 2) Fallback pro array `messages` (só tem a última mensagem, e pode ser
|
||||
// uma activity — filtra pra garantir).
|
||||
if (conv.messages && conv.messages.length) {
|
||||
const nonActivity = conv.messages.filter(
|
||||
m => m && m.message_type !== 2 && m.message_type !== 'activity'
|
||||
);
|
||||
if (nonActivity.length) return nonActivity[nonActivity.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Chatwoot usa Unix timestamp (segundos) na maior parte dos endpoints e
|
||||
// ISO em alguns. Suporta os dois.
|
||||
function parseCreatedAt(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
return value < 10_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
class InactivityAlertTracker {
|
||||
constructor() {
|
||||
// Map<conversationId, { lastClientAt, firedMinutes: Set<number>, contactName, inboxName }>
|
||||
this.conversations = new Map();
|
||||
this.interval = null;
|
||||
this.enabledGetter = () => true; // injetado pelo actionCable com o store
|
||||
}
|
||||
|
||||
setEnabledGetter(fn) {
|
||||
this.enabledGetter = fn;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.interval) return;
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra ou atualiza que o CLIENTE mandou mensagem em uma conversa aberta.
|
||||
* Zera os thresholds se já existia (porque o relógio recomeça).
|
||||
*/
|
||||
onClientMessage({ conversationId, contactName, inboxName }) {
|
||||
if (!conversationId) return;
|
||||
this.conversations.set(conversationId, {
|
||||
lastClientAt: Date.now(),
|
||||
firedMinutes: new Set(),
|
||||
contactName: contactName || '—',
|
||||
inboxName: inboxName || '',
|
||||
});
|
||||
debug('onClientMessage', { conversationId, contactName, inboxName });
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa a conversa — agente respondeu ou cenário não mais aplicável.
|
||||
* Também dá dismiss no banner pra parar som.
|
||||
*/
|
||||
onAgentReplyOrResolved(conversationId) {
|
||||
if (!conversationId) return;
|
||||
if (this.conversations.has(conversationId)) {
|
||||
this.conversations.delete(conversationId);
|
||||
}
|
||||
aggressiveAlert.dismissForReply(conversationId);
|
||||
if (this.conversations.size === 0) this.stop();
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.enabledGetter()) {
|
||||
debug('tick skip: disabled');
|
||||
return;
|
||||
}
|
||||
if (this.conversations.size === 0) {
|
||||
debug('tick: empty map, stopping interval');
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
debug('tick', { size: this.conversations.size });
|
||||
Array.from(this.conversations.entries()).forEach(
|
||||
([conversationId, entry]) => {
|
||||
const elapsedMin = (now - entry.lastClientAt) / 60000;
|
||||
debug('tick entry', {
|
||||
conversationId,
|
||||
elapsedMin: elapsedMin.toFixed(2),
|
||||
fired: Array.from(entry.firedMinutes),
|
||||
});
|
||||
THRESHOLDS.forEach(t => {
|
||||
if (elapsedMin < t.minutes) return;
|
||||
if (entry.firedMinutes.has(t.minutes)) return;
|
||||
entry.firedMinutes.add(t.minutes);
|
||||
debug('THRESHOLD HIT', {
|
||||
conversationId,
|
||||
minutes: t.minutes,
|
||||
level: t.level,
|
||||
});
|
||||
aggressiveAlert.trigger({
|
||||
conversationId,
|
||||
level: t.level,
|
||||
kind: 'inactivity',
|
||||
contactName: entry.contactName,
|
||||
inboxName: entry.inboxName,
|
||||
minutes: t.minutes,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varre a lista de conversas do store e popula o tracker com aquelas
|
||||
* que estão em 'open' e tiveram o cliente como último remetente.
|
||||
* Usa o `created_at` da última msg como âncora de tempo (não Date.now()),
|
||||
* pra fechar o gap dos thresholds perdidos enquanto a aba estava fechada.
|
||||
*
|
||||
* Se a conversa já está no tracker com timestamp ≥ ao recém-lido, ignora
|
||||
* (mantém o estado dos firedMinutes — evita re-trigger em re-hidratação).
|
||||
*/
|
||||
hydrateFromConversations(conversations) {
|
||||
if (!this.enabledGetter()) {
|
||||
debug('hydrate skip: disabled');
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(conversations) || conversations.length === 0) {
|
||||
debug('hydrate skip: empty list');
|
||||
return;
|
||||
}
|
||||
debug('hydrate start', { total: conversations.length });
|
||||
|
||||
let hydrated = 0;
|
||||
let skippedNotOpen = 0;
|
||||
let skippedNoMsg = 0;
|
||||
let skippedAgentLast = 0;
|
||||
let skippedNoTs = 0;
|
||||
conversations.forEach(conv => {
|
||||
if (!conv || conv.status !== 'open') {
|
||||
skippedNotOpen += 1;
|
||||
return;
|
||||
}
|
||||
const lastMsg = findLastNonActivityMessage(conv);
|
||||
if (!lastMsg) {
|
||||
skippedNoMsg += 1;
|
||||
debug('hydrate skip (no last msg)', { id: conv.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const isClient =
|
||||
lastMsg.sender_type === 'Contact' ||
|
||||
lastMsg.message_type === 0 ||
|
||||
lastMsg.message_type === 'incoming';
|
||||
if (!isClient) {
|
||||
// Última msg foi do agente/bot — garante que não está no tracker
|
||||
if (this.conversations.has(conv.id)) {
|
||||
this.conversations.delete(conv.id);
|
||||
}
|
||||
skippedAgentLast += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastClientAt = parseCreatedAt(lastMsg.created_at);
|
||||
if (!lastClientAt) {
|
||||
skippedNoTs += 1;
|
||||
debug('hydrate skip (bad ts)', {
|
||||
id: conv.id,
|
||||
raw: lastMsg.created_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.conversations.get(conv.id);
|
||||
if (existing && existing.lastClientAt >= lastClientAt) return;
|
||||
|
||||
const contactName =
|
||||
(conv.meta && conv.meta.sender && conv.meta.sender.name) || '';
|
||||
const inboxName = (conv.inbox && conv.inbox.name) || '';
|
||||
|
||||
this.conversations.set(conv.id, {
|
||||
lastClientAt,
|
||||
firedMinutes: new Set(),
|
||||
contactName,
|
||||
inboxName,
|
||||
});
|
||||
hydrated += 1;
|
||||
});
|
||||
|
||||
debug('hydrate done', {
|
||||
hydrated,
|
||||
skippedNotOpen,
|
||||
skippedNoMsg,
|
||||
skippedAgentLast,
|
||||
skippedNoTs,
|
||||
mapSize: this.conversations.size,
|
||||
});
|
||||
|
||||
if (hydrated > 0) {
|
||||
this.start();
|
||||
// Dispara imediatamente — se já passou de algum threshold, o tick
|
||||
// seguinte (20s) detectaria. Mas rodar aqui antecipa em até 20s.
|
||||
this.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inactivityAlertTracker = new InactivityAlertTracker();
|
||||
|
||||
export default inactivityAlertTracker;
|
||||
@ -94,13 +94,6 @@
|
||||
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
|
||||
"SUCCESS_MESSAGE": "Agent password reset successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"LABEL": "Aggressive alert — inboxes",
|
||||
"DESCRIPTION": "Choose which inboxes will trigger the reopened/inactivity banner for this agent.",
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"PICK_INBOXES": "Select inboxes",
|
||||
"NONE_WARNING": "No inbox selected — this agent will not see the aggressive alert."
|
||||
}
|
||||
},
|
||||
"SEARCH": {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"HEADLINE_REOPENED": "🚨 Conversation reopened — reply now",
|
||||
"HEADLINE_5": "⏰ {minutes} min without reply",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN WITHOUT REPLY — respond!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN WITHOUT REPLY — conversation will auto-close!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversations awaiting reply",
|
||||
"EXPLANATION": "This alert only clears when you REPLY to the conversation. Clicking × hides temporarily — it comes back if you do not reply.",
|
||||
"KIND_REOPENED": "just reopened",
|
||||
"KIND_WAITING": "{minutes} min without reply",
|
||||
"HIDE_ONE": "Hide",
|
||||
"HIDE_ONE_TITLE": "Hide temporarily — comes back if you do not reply",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
@ -433,6 +433,46 @@
|
||||
"LABEL": "Available for agent sending"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOTIFICATIONS": {
|
||||
"TITLE": "Automatic Notifications",
|
||||
"DESCRIPTION": "Configure messages sent automatically before or after the guest's arrival.",
|
||||
"LOADING": "Loading notifications...",
|
||||
"ADD": "Add notification",
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive",
|
||||
"DIRECTION": {
|
||||
"BEFORE": "before",
|
||||
"AFTER": "after",
|
||||
"OF_ARRIVAL": "arrival"
|
||||
},
|
||||
"FORM": {
|
||||
"LABEL_PLACEHOLDER": "Template name (e.g. Arrival Instructions)",
|
||||
"CONTENT_PLACEHOLDER": "Message to send... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
|
||||
"SEND": "Send",
|
||||
"MINUTES": "min",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVE": "Save"
|
||||
},
|
||||
"CREATE": {
|
||||
"SUCCESS": "Notification created successfully!",
|
||||
"ERROR": "Error creating notification. Please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Notification updated!",
|
||||
"ERROR": "Error updating notification."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Notification removed.",
|
||||
"ERROR": "Error removing notification."
|
||||
},
|
||||
"INBOX_LABEL": "Select inbox",
|
||||
"NO_CAPTAIN_INBOXES": "No inboxes with Captain configured.",
|
||||
"SELECT_INBOX_HINT": "Click an inbox above to view and configure its templates.",
|
||||
"EMPTY": {
|
||||
"TITLE": "No templates configured",
|
||||
"DESC": "Create automatic message templates for this inbox."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@ -891,49 +931,5 @@
|
||||
"RESERVATION_ID": "Reservation #"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_HERMES_BUILDER": {
|
||||
"TITLE": "Agent Builder",
|
||||
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
|
||||
"HEADER_TITLE": "Agent Builder",
|
||||
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
|
||||
"RESET": "Clear conversation",
|
||||
"RESET_CONFIRM": "Clear current conversation with the Builder?",
|
||||
"EMPTY_STATE": "Ready to create a new Hermes agent? Click \"Start creation\" and the Builder will guide you.",
|
||||
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
|
||||
"SEND": "Send",
|
||||
"SESSION_LABEL": "Session:",
|
||||
"SEND_FAILED": "Send failed: {message}",
|
||||
"RESET_FAILED": "Failed to clear session.",
|
||||
"START": "Start creation",
|
||||
"TAB_CHAT": "Chat (Builder)",
|
||||
"TAB_VERIFY": "Verification",
|
||||
"VERIFY": {
|
||||
"TITLE": "Agent verification",
|
||||
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
|
||||
"NO_ASSISTANTS": "No Hermes agents registered",
|
||||
"RUN": "Run check",
|
||||
"RUNNING": "Checking...",
|
||||
"REPAIR": "Repair",
|
||||
"REPAIRING": "Repairing...",
|
||||
"OK_LABEL": "OK",
|
||||
"FAILS_LABEL": "failures",
|
||||
"WARN_LABEL": "warnings",
|
||||
"OF_TOTAL": "of {total} checks",
|
||||
"VERDICT_PASS": "Ready to ship",
|
||||
"VERDICT_FAIL": "Critical failures — fix first",
|
||||
"EMPTY": "Select an agent and click Run check to start verification.",
|
||||
"EMPTY_RESULTS": "No checks returned — agent removed?",
|
||||
"REPAIR_FAILED": "Failed: {message}",
|
||||
"REPAIR_OK": "Repaired: {message}",
|
||||
"FETCH_FAILED": "Error loading assistants: {message}",
|
||||
"VALIDATE_FAILED": "Validation failed: {message}",
|
||||
"CATEGORY_DB": "Database",
|
||||
"CATEGORY_PRICING": "Pricing",
|
||||
"CATEGORY_ROUTING": "Captain → Hermes routing",
|
||||
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
|
||||
"CATEGORY_MCP": "Registered MCP tools",
|
||||
"CATEGORY_OTHER": "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,14 +104,6 @@
|
||||
"ERROR": "Failed to update audio transcription setting"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Aggressive conversation alert (master switch)",
|
||||
"NOTE": "When on, agents receive a banner + sound + OS notification when a conversation is reopened and at 5/15/28 min without reply. Each agent can still turn it off in their profile — this is the account-wide master. Off here = nobody receives.",
|
||||
"API": {
|
||||
"SUCCESS": "Aggressive alert setting updated",
|
||||
"ERROR": "Failed to update aggressive alert setting"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Inactivity duration for resolution",
|
||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import aggressiveBanner from './aggressiveBanner.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -45,7 +44,6 @@ import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...aggressiveBanner,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -385,11 +385,7 @@
|
||||
"ASSISTANTS": "Assistants",
|
||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||
"NEW_ASSISTANT": "Create Assistant",
|
||||
"EMPTY_LIST": "No assistants found, please create one to get started",
|
||||
"ENGINE_HERMES": "Hermes",
|
||||
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
|
||||
"ENGINE_INTERNO": "Internal",
|
||||
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
|
||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TITLE": "Copilot",
|
||||
|
||||
@ -36,11 +36,11 @@
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolved by bot",
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Transferred to human",
|
||||
"NAME": "Handoff Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
@ -281,35 +281,6 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"TABS": {
|
||||
"OVERVIEW": "Overview",
|
||||
"LEADS": "New × Returning"
|
||||
},
|
||||
"LEADS": {
|
||||
"TITLE": "New × Returning",
|
||||
"INBOX_LABEL": "Inbox:",
|
||||
"EMPTY": "No conversations in this period.",
|
||||
"TOTAL": "Total conversations in the period: {count}",
|
||||
"METRICS": {
|
||||
"NEW_LEADS": {
|
||||
"LABEL": "New leads",
|
||||
"INFO": "Conversations from contacts who never spoke to any inbox of the network before."
|
||||
},
|
||||
"RETURNING": {
|
||||
"LABEL": "Returning",
|
||||
"INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago."
|
||||
},
|
||||
"OTHERS": {
|
||||
"LABEL": "Others",
|
||||
"INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago."
|
||||
}
|
||||
},
|
||||
"CHART": {
|
||||
"NEW_LEADS": "New",
|
||||
"RETURNING": "Returning",
|
||||
"OTHERS": "Others"
|
||||
}
|
||||
},
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
@ -534,32 +505,20 @@
|
||||
"HEADER": "Bot Reports",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot in the period"
|
||||
"LABEL": "No. of Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Outgoing messages",
|
||||
"TOOLTIP": "Total number of outgoing messages — includes the bot AND humans (Chatwoot UI or WhatsApp echo)"
|
||||
"LABEL": "Total Responses",
|
||||
"TOOLTIP": "Total number of responses sent by the bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolved by bot %",
|
||||
"TOOLTIP": "Conversations the bot resolved alone (no human reply, via UI or WhatsApp) ÷ total conversations × 100"
|
||||
"LABEL": "Resolution Rate",
|
||||
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Transferred to human %",
|
||||
"TOOLTIP": "Conversations transferred to human (auto by Jasmine + manual takeover) ÷ total conversations × 100. Together with the resolution rate, the gear closes the math (the rest are still open, snoozed, or abandoned)."
|
||||
},
|
||||
"BOT_RESOLUTIONS": {
|
||||
"LABEL": "Resolved by bot",
|
||||
"TOOLTIP": "Absolute count: conversations the bot closed alone, with no human reply (UI or WhatsApp)"
|
||||
},
|
||||
"AUTO_HANDOFFS": {
|
||||
"LABEL": "Auto handoff (Jasmine)",
|
||||
"TOOLTIP": "Conversations where Jasmine explicitly called bot_handoff! — typically tool loop, timeout, max turns reached or LLM intent classified as handoff"
|
||||
},
|
||||
"MANUAL_TAKEOVERS": {
|
||||
"LABEL": "Manual takeover (agent)",
|
||||
"TOOLTIP": "Conversations where a human replied (Chatwoot UI or WhatsApp echo) without Jasmine triggering bot_handoff! first — the agent took over silently"
|
||||
"LABEL": "Handoff Rate",
|
||||
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -687,47 +646,5 @@
|
||||
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
||||
"RESOLUTION_COUNT": "Resolution Count",
|
||||
"CONVERSATIONS": "No. of conversations"
|
||||
},
|
||||
"DIRECTORY_DASHBOARD": {
|
||||
"HEADER": "Directory Dashboard",
|
||||
"BANNER": {
|
||||
"TITLE": "Channel adoption — not the full picture.",
|
||||
"BODY": "These numbers measure the digital channel only (Jasmine + reservations created via app). Conversations attended manually that closed at the reception are not yet captured (manual marking is in progress)."
|
||||
},
|
||||
"HEADLINE_NUMBERS": "Headline numbers",
|
||||
"METRICS": {
|
||||
"LEADS_TOTAL": {
|
||||
"LABEL": "Leads (total)",
|
||||
"TOOLTIP": "All conversations created in the period (new + returning)"
|
||||
},
|
||||
"LEADS_NEW": {
|
||||
"LABEL": "New leads",
|
||||
"TOOLTIP": "First-ever conversation of the contact in any inbox of the network"
|
||||
},
|
||||
"LEADS_RETURNING": {
|
||||
"LABEL": "Returning leads",
|
||||
"TOOLTIP": "Contact had at least one prior conversation"
|
||||
},
|
||||
"CONVERSION_RATE": {
|
||||
"LABEL": "Lead → Paid reservation",
|
||||
"TOOLTIP": "Paid reservations ÷ total leads × 100. Adoption proxy, not full operation."
|
||||
}
|
||||
},
|
||||
"FUNNEL": {
|
||||
"TITLE": "Funnel",
|
||||
"STAGE_LEADS": "Leads",
|
||||
"STAGE_RESERVATIONS": "Reservations created",
|
||||
"STAGE_PAID": "Paid"
|
||||
},
|
||||
"BENCHMARK": {
|
||||
"TITLE": "Inbox benchmarking by brand",
|
||||
"BRAND_AVG": "brand avg.",
|
||||
"COL_INBOX": "Inbox",
|
||||
"COL_LEADS": "Leads",
|
||||
"COL_CREATED": "Created",
|
||||
"COL_PAID": "Paid",
|
||||
"COL_RATE": "Conv. rate",
|
||||
"COL_VS_BRAND": "vs brand"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Aggressive conversation alert",
|
||||
"SECTION_NOTE": "Triggers a banner, sound and OS notification when a conversation is reopened and every 5/15/28 minutes without reply. Only clears when YOU reply. Turn off for a silent shift — but own the risk.",
|
||||
"TITLE": "Receive aggressive alerts",
|
||||
"NOTE": "When on, you get a banner + sound + notification when a conversation reopens or goes X minutes without reply. Turn off at your own risk."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||
@ -121,15 +115,6 @@
|
||||
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||
},
|
||||
"AGGRESSIVE_ALERT_SECTION": {
|
||||
"TITLE": "Stalled conversation alert",
|
||||
"NOTE": "Red banner that appears at the top of the panel when a conversation has been waiting for a reply for 5+ minutes.",
|
||||
"DESCRIPTION": "Red banner shown when a conversation has no reply for 5+ minutes. Useful to avoid losing customers, but can be intrusive if you don't handle every inbox.",
|
||||
"ENABLED": "Enable stalled conversation alert",
|
||||
"APPLY_TO_ALL": "Apply to all inboxes",
|
||||
"INBOX_HINT": "Pick the inboxes where you want to receive the alert:",
|
||||
"NO_INBOXES": "No inboxes registered."
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Audio Alerts",
|
||||
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
|
||||
@ -359,10 +344,10 @@
|
||||
"CAPTAIN_GALLERY": "Gallery",
|
||||
"CAPTAIN_RESERVATIONS": "Reservations",
|
||||
"CAPTAIN_ROLETA": "Roulette — Redeem",
|
||||
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
|
||||
"CAPTAIN_FUNNEL": "Conversion Funnel",
|
||||
"CAPTAIN_LIFECYCLE": "Customer Journey",
|
||||
"CAPTAIN_REPORTS": "AI Reports",
|
||||
"CAPTAIN_NOTIFICATIONS": "Automatic Notifications",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
@ -398,7 +383,6 @@
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_DIRECTORY_DASHBOARD": "Directory Dashboard",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
|
||||
@ -94,13 +94,6 @@
|
||||
"ADMIN_SUCCESS_MESSAGE": "Um e-mail com instruções de redefinição de senha foi enviado para o agente",
|
||||
"SUCCESS_MESSAGE": "Senha do agente redefinida com sucesso",
|
||||
"ERROR_MESSAGE": "Não foi possível conectar ao servidor Woot, por favor tente novamente mais tarde"
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"LABEL": "Alerta agressivo — caixas de entrada",
|
||||
"DESCRIPTION": "Escolha em quais caixas de entrada este agente verá o banner de conversa reaberta e de inatividade.",
|
||||
"ALL_INBOXES": "Em todas as caixas de entrada",
|
||||
"PICK_INBOXES": "Selecione as caixas de entrada",
|
||||
"NONE_WARNING": "Nenhuma caixa selecionada — este agente não verá o alerta agressivo."
|
||||
}
|
||||
},
|
||||
"SEARCH": {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"AGGRESSIVE_CONVERSATION_BANNER": {
|
||||
"HEADLINE_REOPENED": "🚨 Conversa reaberta — responda agora",
|
||||
"HEADLINE_5": "⏰ {minutes} min sem resposta",
|
||||
"HEADLINE_15": "⚠️ {minutes} MIN SEM RESPOSTA — responda!",
|
||||
"HEADLINE_28": "🚨 {minutes} MIN SEM RESPOSTA — conversa vai fechar!",
|
||||
"HEADLINE_MULTIPLE": "🚨 {count} conversas aguardando resposta",
|
||||
"EXPLANATION": "Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente — volta se não responder.",
|
||||
"KIND_REOPENED": "reabriu agora",
|
||||
"KIND_WAITING": "{minutes} min sem resposta",
|
||||
"HIDE_ONE": "Esconder",
|
||||
"HIDE_ONE_TITLE": "Esconde temporariamente — volta se não responder",
|
||||
"HIDE_ICON": "×"
|
||||
}
|
||||
}
|
||||
@ -45,7 +45,7 @@
|
||||
"HIDE": "Ocultar filtros"
|
||||
},
|
||||
"KPI": {
|
||||
"TOTAL": "Total filtrado",
|
||||
"TOTAL": "Total na página",
|
||||
"PENDING_PIX": "Aguardando PIX",
|
||||
"CHECKIN_TODAY": "Check-in hoje",
|
||||
"REVENUE_TODAY": "Receita hoje"
|
||||
@ -434,6 +434,47 @@
|
||||
"LABEL": "Disponível para envio pelos agentes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOTIFICATIONS": {
|
||||
"TITLE": "Notificações Automáticas",
|
||||
"DESCRIPTION": "Configure mensagens automáticas enviadas antes ou depois da chegada do hóspede.",
|
||||
"LOADING": "Carregando notificações...",
|
||||
"ADD": "Adicionar notificação",
|
||||
"ACTIVE": "Ativo",
|
||||
"INACTIVE": "Inativo",
|
||||
"DIRECTION": {
|
||||
"BEFORE": "antes",
|
||||
"AFTER": "depois",
|
||||
"OF_ARRIVAL": "da chegada"
|
||||
},
|
||||
"TIMING_LABEL": "da chegada",
|
||||
"FORM": {
|
||||
"LABEL_PLACEHOLDER": "Nome do template (ex: Orientações de Chegada)",
|
||||
"CONTENT_PLACEHOLDER": "Mensagem a enviar... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
|
||||
"SEND": "Enviar",
|
||||
"MINUTES": "min",
|
||||
"CANCEL": "Cancelar",
|
||||
"SAVE": "Salvar"
|
||||
},
|
||||
"CREATE": {
|
||||
"SUCCESS": "Notificação criada com sucesso!",
|
||||
"ERROR": "Erro ao criar notificação. Tente novamente."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Notificação atualizada!",
|
||||
"ERROR": "Erro ao atualizar notificação."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Notificação removida.",
|
||||
"ERROR": "Erro ao remover notificação."
|
||||
},
|
||||
"INBOX_LABEL": "Selecione a caixa de entrada",
|
||||
"NO_CAPTAIN_INBOXES": "Nenhuma caixa de entrada com Captain configurado.",
|
||||
"SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.",
|
||||
"EMPTY": {
|
||||
"TITLE": "Nenhum template configurado",
|
||||
"DESC": "Configure as permissões das informações que o sistema utiliza. Por ex.: Quais imagens enviar durante as aproximações."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
@ -892,49 +933,5 @@
|
||||
"RESERVATION_ID": "Reserva #"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN_HERMES_BUILDER": {
|
||||
"TITLE": "Construtor de Agentes",
|
||||
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
|
||||
"HEADER_TITLE": "Construtor de Agentes",
|
||||
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
|
||||
"RESET": "Limpar conversa",
|
||||
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
|
||||
"EMPTY_STATE": "Pronto pra criar um novo agente Hermes? Clica em \"Iniciar criação\" e o Construtor te guia.",
|
||||
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
|
||||
"SEND": "Enviar",
|
||||
"SESSION_LABEL": "Sessão:",
|
||||
"SEND_FAILED": "Erro ao enviar: {message}",
|
||||
"RESET_FAILED": "Falha ao limpar sessão.",
|
||||
"START": "Iniciar criação",
|
||||
"TAB_CHAT": "Chat (Construtor)",
|
||||
"TAB_VERIFY": "Verificação",
|
||||
"VERIFY": {
|
||||
"TITLE": "Verificação de agente",
|
||||
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
|
||||
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
|
||||
"RUN": "Conferir agora",
|
||||
"RUNNING": "Conferindo...",
|
||||
"REPAIR": "Refazer",
|
||||
"REPAIRING": "Reparando...",
|
||||
"OK_LABEL": "OK",
|
||||
"FAILS_LABEL": "falhas",
|
||||
"WARN_LABEL": "atenção",
|
||||
"OF_TOTAL": "de {total} checks",
|
||||
"VERDICT_PASS": "Pode soltar",
|
||||
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
|
||||
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
|
||||
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
|
||||
"REPAIR_FAILED": "Falha: {message}",
|
||||
"REPAIR_OK": "Reparado: {message}",
|
||||
"FETCH_FAILED": "Erro carregando assistentes: {message}",
|
||||
"VALIDATE_FAILED": "Falha ao validar: {message}",
|
||||
"CATEGORY_DB": "Banco de dados",
|
||||
"CATEGORY_PRICING": "Preços",
|
||||
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
|
||||
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
|
||||
"CATEGORY_MCP": "Tools MCP registradas",
|
||||
"CATEGORY_OTHER": "Outros"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,14 +104,6 @@
|
||||
"ERROR": "Falha ao atualizar configuração de transcrição de áudio"
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"TITLE": "Alerta agressivo de conversa (master switch)",
|
||||
"NOTE": "Quando ligado, atendentes recebem banner + som + notificação do SO quando uma conversa é reaberta e a cada 5/15/28 min sem resposta. Cada agente ainda pode desligar pra si no próprio perfil — este toggle é o mestre da conta. Desligar aqui = ninguém recebe.",
|
||||
"API": {
|
||||
"SUCCESS": "Alerta agressivo atualizado",
|
||||
"ERROR": "Falha ao atualizar o alerta agressivo"
|
||||
}
|
||||
},
|
||||
"AUTO_RESOLVE_DURATION": {
|
||||
"LABEL": "Tempo de inatividade para resolução",
|
||||
"HELP": "Tempo de inatividade após o qual a conversa deve ser encerrada automaticamente",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import aggressiveBanner from './aggressiveBanner.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -41,7 +40,6 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...aggressiveBanner,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -366,11 +366,7 @@
|
||||
"ASSISTANTS": "Assistentes",
|
||||
"SWITCH_ASSISTANT": "Alternar entre assistentes",
|
||||
"NEW_ASSISTANT": "Criar Assistente",
|
||||
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
|
||||
"ENGINE_HERMES": "Hermes",
|
||||
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
|
||||
"ENGINE_INTERNO": "Interno",
|
||||
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
|
||||
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TITLE": "Copiloto",
|
||||
|
||||
@ -36,11 +36,11 @@
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolvidas pelo bot",
|
||||
"NAME": "Contagem de Resolução",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Transferidas para humano",
|
||||
"NAME": "Contagem de transferências",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
@ -269,37 +269,8 @@
|
||||
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
|
||||
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
|
||||
"ALL_INBOXES": "Todas as caixas",
|
||||
"SEARCH_INBOX": "Buscar caixa",
|
||||
"TABS": {
|
||||
"OVERVIEW": "Visão Geral",
|
||||
"LEADS": "Novas × Retorno"
|
||||
},
|
||||
"LEADS": {
|
||||
"TITLE": "Novas × Retorno",
|
||||
"INBOX_LABEL": "Caixa de entrada:",
|
||||
"EMPTY": "Sem conversas no período.",
|
||||
"TOTAL": "Total de conversas no período: {count}",
|
||||
"METRICS": {
|
||||
"NEW_LEADS": {
|
||||
"LABEL": "Leads novos",
|
||||
"INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes."
|
||||
},
|
||||
"RETURNING": {
|
||||
"LABEL": "Retorno",
|
||||
"INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h."
|
||||
},
|
||||
"OTHERS": {
|
||||
"LABEL": "Outras",
|
||||
"INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h."
|
||||
}
|
||||
},
|
||||
"CHART": {
|
||||
"NEW_LEADS": "Novas",
|
||||
"RETURNING": "Retorno",
|
||||
"OTHERS": "Outras"
|
||||
}
|
||||
},
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversas",
|
||||
@ -466,32 +437,20 @@
|
||||
"HEADER": "Relatórios do Bot",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "Conversas",
|
||||
"TOOLTIP": "Total de conversas atendidas pelo bot no período"
|
||||
"LABEL": "Nº de Conversas",
|
||||
"TOOLTIP": "Número total de conversas tratadas pelo bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Mensagens enviadas",
|
||||
"TOOLTIP": "Total de mensagens enviadas — inclui o bot E humanos (via Chatwoot ou eco do WhatsApp)"
|
||||
"LABEL": "Total de respostas",
|
||||
"TOOLTIP": "Número total de respostas enviadas pelo bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolvidas pelo bot %",
|
||||
"TOOLTIP": "Conversas que o bot resolveu sozinho (sem humano respondendo, via Chatwoot ou WhatsApp) ÷ total de conversas × 100"
|
||||
"LABEL": "Tempo de resolução",
|
||||
"TOOLTIP": "Número total de conversas resolvidas pelo bot / número total de conversas manipuladas pelo bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Transferidas pra humano %",
|
||||
"TOOLTIP": "Conversas transferidas pra humano (auto pela Jasmine + tomada manual) ÷ total de conversas × 100. Junto com a taxa de resolução fecha a engrenagem (o resto está aberto, em snooze ou abandonado)."
|
||||
},
|
||||
"BOT_RESOLUTIONS": {
|
||||
"LABEL": "Resolvidas pelo bot",
|
||||
"TOOLTIP": "Contagem absoluta: conversas que o bot fechou sozinho, sem humano respondendo (via Chatwoot ou WhatsApp)"
|
||||
},
|
||||
"AUTO_HANDOFFS": {
|
||||
"LABEL": "Transferência automática (Jasmine)",
|
||||
"TOOLTIP": "Conversas em que a Jasmine chamou bot_handoff! explicitamente — geralmente loop de ferramenta, timeout, limite de turnos ou intent do LLM classificado como handoff"
|
||||
},
|
||||
"MANUAL_TAKEOVERS": {
|
||||
"LABEL": "Tomada manual (agente)",
|
||||
"TOOLTIP": "Conversas em que um humano respondeu (Chatwoot ou eco do WhatsApp) SEM a Jasmine ter chamado bot_handoff! antes — o agente assumiu silenciosamente"
|
||||
"LABEL": "Taxa de entrega",
|
||||
"TOOLTIP": "Número total de conversas entregues a agentes / número total de conversas mantidas pelo bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -619,47 +578,5 @@
|
||||
"AVG_REPLY_TIME": "Tempo Médio de Espera do Cliente",
|
||||
"RESOLUTION_COUNT": "Contagem de Resolução",
|
||||
"CONVERSATIONS": "Nº de Conversas"
|
||||
},
|
||||
"DIRECTORY_DASHBOARD": {
|
||||
"HEADER": "Painel Diretoria",
|
||||
"BANNER": {
|
||||
"TITLE": "Adoção do canal digital — não é a operação completa.",
|
||||
"BODY": "Esses números medem só o canal digital (Jasmine + reservas via app). Conversas atendidas manualmente que fecharam na recepção ainda não estão capturadas (marcação manual em construção)."
|
||||
},
|
||||
"HEADLINE_NUMBERS": "Números principais",
|
||||
"METRICS": {
|
||||
"LEADS_TOTAL": {
|
||||
"LABEL": "Leads (total)",
|
||||
"TOOLTIP": "Todas as conversas criadas no período (novos + retorno)"
|
||||
},
|
||||
"LEADS_NEW": {
|
||||
"LABEL": "Leads novos",
|
||||
"TOOLTIP": "Primeira conversa do contato em qualquer caixa da rede"
|
||||
},
|
||||
"LEADS_RETURNING": {
|
||||
"LABEL": "Leads de retorno",
|
||||
"TOOLTIP": "Contato com pelo menos uma conversa anterior"
|
||||
},
|
||||
"CONVERSION_RATE": {
|
||||
"LABEL": "Lead → Reserva paga",
|
||||
"TOOLTIP": "Reservas pagas ÷ total de leads × 100. Proxy de adoção, não retrato da operação."
|
||||
}
|
||||
},
|
||||
"FUNNEL": {
|
||||
"TITLE": "Funil",
|
||||
"STAGE_LEADS": "Leads",
|
||||
"STAGE_RESERVATIONS": "Reservas criadas",
|
||||
"STAGE_PAID": "Pagas"
|
||||
},
|
||||
"BENCHMARK": {
|
||||
"TITLE": "Comparativo entre unidades por marca",
|
||||
"BRAND_AVG": "média da marca",
|
||||
"COL_INBOX": "Caixa de Entrada",
|
||||
"COL_LEADS": "Leads",
|
||||
"COL_CREATED": "Criadas",
|
||||
"COL_PAID": "Pagas",
|
||||
"COL_RATE": "Taxa conv.",
|
||||
"COL_VS_BRAND": "vs marca"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AGGRESSIVE_ALERT": {
|
||||
"SECTION_TITLE": "Alerta agressivo de conversa",
|
||||
"SECTION_NOTE": "Ativa banner, som e notificação do SO quando uma conversa é reaberta e a cada 5/15/28 minutos sem resposta. Só some quando VOCÊ responder. Desativa se quiser turno silencioso — mas a casa cai se esquecer.",
|
||||
"TITLE": "Receber alertas agressivos",
|
||||
"NOTE": "Se ligado, você recebe banner + som + notificação quando uma conversa é reaberta ou fica X minutos sem resposta. Só desliga se souber o que está fazendo."
|
||||
},
|
||||
"INTERFACE_SECTION": {
|
||||
"TITLE": "Interface",
|
||||
"NOTE": "Personalize a aparência do seu painel do Chatwoot.",
|
||||
@ -121,15 +115,6 @@
|
||||
"RESET_SUCCESS": "Token de acesso gerado novamente com sucesso",
|
||||
"RESET_ERROR": "Não foi possível regerar o token de acesso. Por favor, tente novamente"
|
||||
},
|
||||
"AGGRESSIVE_ALERT_SECTION": {
|
||||
"TITLE": "Alerta de conversa parada",
|
||||
"NOTE": "Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.",
|
||||
"DESCRIPTION": "Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.",
|
||||
"ENABLED": "Ativar alerta de conversa parada",
|
||||
"APPLY_TO_ALL": "Aplicar em todas as caixas de entrada",
|
||||
"INBOX_HINT": "Selecione as caixas onde você quer receber o alerta:",
|
||||
"NO_INBOXES": "Nenhuma caixa de entrada cadastrada."
|
||||
},
|
||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Alertas de áudio",
|
||||
"NOTE": "Habilitar notificações de áudio no painel para novas mensagens e conversas.",
|
||||
@ -358,10 +343,10 @@
|
||||
"CAPTAIN_GALLERY": "Galeria",
|
||||
"CAPTAIN_RESERVATIONS": "Reservas",
|
||||
"CAPTAIN_ROLETA": "Roleta — Resgate",
|
||||
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
|
||||
"CAPTAIN_FUNNEL": "Funil de Conversão",
|
||||
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
|
||||
"CAPTAIN_REPORTS": "Relatórios IA",
|
||||
"CAPTAIN_NOTIFICATIONS": "Notificações Automáticas",
|
||||
"HOME": "Principal",
|
||||
"AGENTS": "Agentes",
|
||||
"AGENT_BOTS": "Robôs",
|
||||
@ -397,7 +382,6 @@
|
||||
"ONE_OFF": "Única",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Robôs",
|
||||
"REPORTS_DIRECTORY_DASHBOARD": "Painel Diretoria",
|
||||
"REPORTS_AGENT": "Agentes",
|
||||
"REPORTS_LABEL": "Etiquetas",
|
||||
"REPORTS_INBOX": "Caixa de Entrada",
|
||||
|
||||
@ -18,8 +18,6 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const FAQ_QUESTION_MAX_LENGTH = 255;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddCannedModal,
|
||||
@ -95,11 +93,6 @@ export default {
|
||||
this.message.content_attributes ?? this.message.contentAttributes
|
||||
);
|
||||
},
|
||||
faqQuestion() {
|
||||
return (this.plainTextContent || '')
|
||||
.trim()
|
||||
.slice(0, FAQ_QUESTION_MAX_LENGTH);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleEnterKey(e) {
|
||||
@ -258,7 +251,7 @@ export default {
|
||||
ref="faqDialog"
|
||||
type="create"
|
||||
:selected-response="{
|
||||
question: faqQuestion,
|
||||
question: plainTextContent,
|
||||
assistant_id: copilotAssistant?.id,
|
||||
}"
|
||||
@close="hideFaqModal"
|
||||
|
||||
@ -1,362 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const messages = ref([]);
|
||||
const input = ref('');
|
||||
const sending = ref(false);
|
||||
const polling = ref(null);
|
||||
const scrollContainer = ref(null);
|
||||
const sessionId = ref(null);
|
||||
|
||||
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
|
||||
const isWaiting = computed(
|
||||
() => sending.value || lastMessageRole.value === 'user'
|
||||
);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollContainer.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.fetchMessages();
|
||||
messages.value = data.messages || [];
|
||||
sessionId.value = data.session_id;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
// silencioso — polling repete
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.value.trim();
|
||||
if (!text || sending.value) return;
|
||||
sending.value = true;
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
input.value = '';
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
try {
|
||||
await hermesBuilderApi.sendMessage(text);
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const resetSession = async () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
|
||||
try {
|
||||
await hermesBuilderApi.reset();
|
||||
messages.value = [];
|
||||
} catch (e) {
|
||||
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
if (sending.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
await hermesBuilderApi.start();
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = iso => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchMessages();
|
||||
polling.value = setInterval(fetchMessages, 2000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (polling.value) clearInterval(polling.value);
|
||||
});
|
||||
|
||||
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="builder-wrapper">
|
||||
<header class="builder-header">
|
||||
<div>
|
||||
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" @click="resetSession">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section ref="scrollContainer" class="messages">
|
||||
<div v-if="!messages.length" class="empty-state">
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="start-button"
|
||||
:disabled="sending"
|
||||
@click="startSession"
|
||||
>
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="msg"
|
||||
:class="[`msg--${msg.role}`]"
|
||||
>
|
||||
<div class="msg__bubble">
|
||||
<div class="msg__content">{{ msg.content }}</div>
|
||||
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isWaiting" class="msg msg--construtor">
|
||||
<div class="msg__bubble msg__bubble--typing">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="composer">
|
||||
<textarea
|
||||
v-model="input"
|
||||
rows="2"
|
||||
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
|
||||
:disabled="sending"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
:disabled="!input.trim() || sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
<p v-if="sessionId" class="session-debug">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.builder-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 260px);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-background-light, #f7f8fa);
|
||||
border-radius: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: auto;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.start-button {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-woot-600, #1976d2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
|
||||
&--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&--construtor {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.msg__bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-background-light, #f3f4f6);
|
||||
font-size: 14px;
|
||||
|
||||
.msg--user & {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.msg__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg__meta {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.msg__bubble--typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-light, #6b7280);
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.session-debug {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,443 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const assistants = ref([]);
|
||||
const selectedSlug = ref('');
|
||||
const checks = ref([]);
|
||||
const summary = ref(null);
|
||||
const loading = ref(false);
|
||||
const repairing = ref({});
|
||||
|
||||
const groupedChecks = computed(() => {
|
||||
const groups = {};
|
||||
checks.value.forEach(c => {
|
||||
const cat = c.category || 'outros';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
const categoryLabel = cat => {
|
||||
const map = {
|
||||
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
|
||||
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
|
||||
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
|
||||
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
|
||||
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
|
||||
};
|
||||
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
|
||||
};
|
||||
|
||||
const fetchAssistants = async () => {
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.fetchAssistants();
|
||||
assistants.value = data.assistants || [];
|
||||
if (assistants.value.length && !selectedSlug.value) {
|
||||
selectedSlug.value = assistants.value[0].slug;
|
||||
}
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
|
||||
message: e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const runValidation = async () => {
|
||||
if (!selectedSlug.value || loading.value) return;
|
||||
loading.value = true;
|
||||
checks.value = [];
|
||||
summary.value = null;
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
|
||||
checks.value = data.results || [];
|
||||
summary.value = data;
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runRepair = async check => {
|
||||
if (!check.repair_id) return;
|
||||
repairing.value[check.repair_id] = true;
|
||||
try {
|
||||
const { data } = await hermesBuilderApi.repair(
|
||||
selectedSlug.value,
|
||||
check.repair_id
|
||||
);
|
||||
if (data.ok) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
|
||||
message: data.message || 'OK',
|
||||
})
|
||||
);
|
||||
await runValidation();
|
||||
} else {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||
message: data.error || 'unknown',
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
useAlert(
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||
message: e.response?.data?.error || e.message || 'unknown',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
repairing.value[check.repair_id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = status => {
|
||||
if (status === 'PASS') return '✓';
|
||||
if (status === 'FAIL') return '✗';
|
||||
if (status === 'WARN') return '⚠';
|
||||
return '?';
|
||||
};
|
||||
|
||||
const statusClass = status => {
|
||||
if (status === 'PASS') return 'badge--pass';
|
||||
if (status === 'FAIL') return 'badge--fail';
|
||||
if (status === 'WARN') return 'badge--warn';
|
||||
return 'badge--unknown';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAssistants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="verification-wrapper">
|
||||
<header class="verification-header">
|
||||
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
|
||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
v-model="selectedSlug"
|
||||
class="select"
|
||||
:disabled="!assistants.length || loading"
|
||||
>
|
||||
<option v-if="!assistants.length" value="">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
|
||||
</option>
|
||||
<option v-for="a in assistants" :key="a.id" :value="a.slug">
|
||||
{{ a.name }} — {{ a.slug }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
:disabled="!selectedSlug || loading"
|
||||
@click="runValidation"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
|
||||
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="summary">
|
||||
<span class="summary__item summary__item--pass">
|
||||
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
|
||||
</span>
|
||||
<span v-if="summary.fail" class="summary__item summary__item--fail">
|
||||
{{ summary.fail }}
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
|
||||
</span>
|
||||
<span v-if="summary.warn" class="summary__item summary__item--warn">
|
||||
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
|
||||
</span>
|
||||
<span class="summary__total">
|
||||
{{
|
||||
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
|
||||
}}
|
||||
</span>
|
||||
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
|
||||
✅ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
|
||||
</span>
|
||||
<span v-else class="summary__verdict summary__verdict--fail">
|
||||
❌ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section v-if="checks.length" class="checks-section">
|
||||
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
|
||||
<h3 class="check-group__title">
|
||||
{{ categoryLabel(cat) }}
|
||||
</h3>
|
||||
<ul class="check-list">
|
||||
<li
|
||||
v-for="(check, idx) in items"
|
||||
:key="idx"
|
||||
class="check-item"
|
||||
:class="`check-item--${check.status.toLowerCase()}`"
|
||||
>
|
||||
<span class="check-item__badge" :class="statusClass(check.status)">
|
||||
{{ statusIcon(check.status) }}
|
||||
</span>
|
||||
<div class="check-item__body">
|
||||
<div class="check-item__label">{{ check.label }}</div>
|
||||
<div v-if="check.detail" class="check-item__detail">
|
||||
{{ check.detail }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="
|
||||
check.repair_id &&
|
||||
(check.status === 'FAIL' || check.status === 'WARN')
|
||||
"
|
||||
type="button"
|
||||
class="repair-btn"
|
||||
:disabled="repairing[check.repair_id]"
|
||||
@click="runRepair(check)"
|
||||
>
|
||||
{{
|
||||
repairing[check.repair_id]
|
||||
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
|
||||
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
|
||||
}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-else-if="!loading && summary" class="empty-state">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
|
||||
</p>
|
||||
<p v-else-if="!loading" class="empty-state">
|
||||
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.verification-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.verification-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--color-background-light, #f7f8fa);
|
||||
border-radius: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.select {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-background, #fff);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-woot-500, #1f93ff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-background, #fff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__item {
|
||||
font-weight: 600;
|
||||
|
||||
&--pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
&--fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
&--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&__total {
|
||||
color: var(--color-text-light, #6b7280);
|
||||
}
|
||||
|
||||
&__verdict {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
|
||||
&--pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
&--fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checks-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.check-group {
|
||||
background: var(--color-background, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.check-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&--fail {
|
||||
background: #fef2f2;
|
||||
}
|
||||
&--warn {
|
||||
background: #fffbeb;
|
||||
}
|
||||
}
|
||||
|
||||
.check-item__badge {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
&.badge--pass {
|
||||
background: #16a34a;
|
||||
}
|
||||
&.badge--fail {
|
||||
background: #dc2626;
|
||||
}
|
||||
&.badge--warn {
|
||||
background: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.check-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.check-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check-item__detail {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.repair-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-woot-500, #1f93ff);
|
||||
background: var(--color-background, #fff);
|
||||
color: var(--color-woot-500, #1f93ff);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-woot-500, #1f93ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
font-size: 14px;
|
||||
padding: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import BuilderChat from './BuilderChat.vue';
|
||||
import BuilderVerification from './BuilderVerification.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
|
||||
]);
|
||||
|
||||
const activeIndex = ref(0);
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
|
||||
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
|
||||
>
|
||||
<div class="builder-tabs">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
</div>
|
||||
<div class="builder-panels">
|
||||
<BuilderChat v-show="activeIndex === 0" />
|
||||
<BuilderVerification v-show="activeIndex === 1" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.builder-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.builder-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,5 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
@ -19,7 +18,6 @@ import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
import ReservationsIndex from './reservations/Index.vue';
|
||||
import RoletaIndex from './roleta/Index.vue';
|
||||
import HermesBuilderIndex from './builder/Index.vue';
|
||||
import FunnelIndex from './funnel/Index.vue';
|
||||
import LifecycleIndex from './lifecycle/Index.vue';
|
||||
import LifecycleRules from './lifecycle/Rules.vue';
|
||||
@ -32,11 +30,6 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const knowledgeBaseMeta = {
|
||||
...meta,
|
||||
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
|
||||
};
|
||||
|
||||
const metaV2 = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||
@ -48,13 +41,13 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
||||
component: ResponsesIndex,
|
||||
name: 'captain_assistants_responses_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
|
||||
component: DocumentsIndex,
|
||||
name: 'captain_assistants_documents_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
@ -84,7 +77,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
@ -125,7 +118,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
|
||||
component: AssistantsIndexPage,
|
||||
name: 'captain_assistants_index',
|
||||
meta: knowledgeBaseMeta,
|
||||
meta,
|
||||
},
|
||||
];
|
||||
|
||||
@ -156,19 +149,6 @@ export const routes = [
|
||||
name: 'captain_roleta_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
|
||||
component: HermesBuilderIndex,
|
||||
name: 'captain_hermes_builder_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/funnel'),
|
||||
component: FunnelIndex,
|
||||
|
||||
@ -102,7 +102,7 @@ const groupedReservations = computed(() => {
|
||||
return groups;
|
||||
});
|
||||
|
||||
const pageStatusCounts = computed(() => {
|
||||
const statusCounts = computed(() => {
|
||||
const counts = {
|
||||
all: reservations.value.length,
|
||||
draft: 0,
|
||||
@ -117,23 +117,6 @@ const pageStatusCounts = computed(() => {
|
||||
return counts;
|
||||
});
|
||||
|
||||
const statusCounts = computed(() => {
|
||||
const metaCounts = reservationsMeta.value.statusCounts || {};
|
||||
return {
|
||||
all: Number(
|
||||
metaCounts.all ??
|
||||
reservationsMeta.value.totalCount ??
|
||||
pageStatusCounts.value.all
|
||||
),
|
||||
draft: Number(metaCounts.draft ?? pageStatusCounts.value.draft),
|
||||
pending_payment: Number(
|
||||
metaCounts.pending_payment ?? pageStatusCounts.value.pending_payment
|
||||
),
|
||||
confirmed: Number(metaCounts.confirmed ?? pageStatusCounts.value.confirmed),
|
||||
cancelled: Number(metaCounts.cancelled ?? pageStatusCounts.value.cancelled),
|
||||
};
|
||||
});
|
||||
|
||||
const todayRevenue = computed(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@ -6,8 +6,6 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@ -26,7 +24,6 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { checkPermissions } = usePolicy();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
@ -41,10 +38,6 @@ const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
const canManageResponses = computed(() =>
|
||||
checkPermissions(responseManagePermissions)
|
||||
);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
@ -213,7 +206,7 @@ onMounted(() => {
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="responseManagePermissions"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
@ -254,7 +247,6 @@ onMounted(() => {
|
||||
|
||||
<template #subHeader>
|
||||
<BulkSelectBar
|
||||
v-if="canManageResponses"
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="responses"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
@ -301,11 +293,8 @@ onMounted(() => {
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="
|
||||
canManageResponses &&
|
||||
(hoveredCard === response.id || bulkSelectedIds.size > 0)
|
||||
"
|
||||
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
:show-actions="false"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
|
||||
@ -7,8 +7,6 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@ -27,7 +25,6 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { checkPermissions } = usePolicy();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
@ -43,10 +40,6 @@ const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||
const canManageResponses = computed(() =>
|
||||
checkPermissions(responseManagePermissions)
|
||||
);
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
@ -293,7 +286,6 @@ onMounted(() => {
|
||||
|
||||
<template #subHeader>
|
||||
<BulkSelectBar
|
||||
v-if="canManageResponses"
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="filteredResponses"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
@ -346,14 +338,9 @@ onMounted(() => {
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="
|
||||
canManageResponses &&
|
||||
(hoveredCard === response.id || bulkSelectedIds.size > 0)
|
||||
"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="
|
||||
canManageResponses && !bulkSelectedIds.has(response.id)
|
||||
"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
|
||||
@ -15,7 +15,6 @@ import AccountId from './components/AccountId.vue';
|
||||
import BuildInfo from './components/BuildInfo.vue';
|
||||
import AccountDelete from './components/AccountDelete.vue';
|
||||
import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import AggressiveAlertSetting from './components/AggressiveAlertSetting.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
@ -26,7 +25,6 @@ export default {
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AudioTranscription,
|
||||
AggressiveAlertSetting,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
@ -234,7 +232,6 @@ export default {
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AggressiveAlertSetting />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SectionLayout from './SectionLayout.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
// Default true — quando account ainda não carregou, assume ligado.
|
||||
const isEnabled = ref(true);
|
||||
|
||||
const { currentAccount, updateAccount } = useAccount();
|
||||
|
||||
watch(
|
||||
currentAccount,
|
||||
() => {
|
||||
const settings = currentAccount.value?.settings || {};
|
||||
// Só trata como false se explicitamente false; qualquer outro valor = ligado.
|
||||
isEnabled.value = settings.aggressive_alert_enabled !== false;
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
try {
|
||||
await updateAccount({
|
||||
aggressive_alert_enabled: isEnabled.value,
|
||||
});
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE')"
|
||||
:description="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE')"
|
||||
with-border
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggle" />
|
||||
</div>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
@ -6,7 +6,6 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import Auth from '../../../../api/auth';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
@ -39,10 +38,6 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
uiSettings: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@ -57,27 +52,6 @@ const agentAvailability = ref(props.availability);
|
||||
const selectedRoleId = ref(props.customRoleId || props.type);
|
||||
const agentCredentials = ref({ email: props.email });
|
||||
|
||||
// --- Alerta agressivo por inbox -------------------------------------------
|
||||
// ui_settings.aggressive_alert_inbox_ids:
|
||||
// null/undefined → todas (default, legado)
|
||||
// [] → nenhuma (silenciou tudo)
|
||||
// [1, 2, 3] → só essas
|
||||
const initialInboxIds = props.uiSettings?.aggressive_alert_inbox_ids;
|
||||
const alertAllInboxes = ref(
|
||||
initialInboxIds === null ||
|
||||
initialInboxIds === undefined ||
|
||||
!Array.isArray(initialInboxIds)
|
||||
);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const selectedAlertInboxes = ref(
|
||||
Array.isArray(initialInboxIds) && inboxes.value
|
||||
? inboxes.value.filter(i =>
|
||||
initialInboxIds.map(id => Number(id)).includes(Number(i.id))
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
const rules = {
|
||||
agentName: { required, minLength: minLength(1) },
|
||||
selectedRoleId: { required },
|
||||
@ -161,12 +135,6 @@ const editAgent = async () => {
|
||||
payload.custom_role_id = null;
|
||||
}
|
||||
|
||||
payload.ui_settings = {
|
||||
aggressive_alert_inbox_ids: alertAllInboxes.value
|
||||
? null
|
||||
: selectedAlertInboxes.value.map(i => Number(i.id)),
|
||||
};
|
||||
|
||||
await store.dispatch('agents/update', payload);
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
emit('close');
|
||||
@ -236,47 +204,6 @@ const resetPassword = async () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="mt-2 border-t pt-3 border-n-weak">
|
||||
<span class="block font-medium mb-1">
|
||||
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.LABEL') }}
|
||||
</span>
|
||||
<p class="text-xs text-n-slate-11 mb-2">
|
||||
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2 mb-2">
|
||||
<input v-model="alertAllInboxes" type="checkbox" class="!m-0" />
|
||||
<span>
|
||||
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.ALL_INBOXES') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!alertAllInboxes">
|
||||
<Multiselect
|
||||
v-model="selectedAlertInboxes"
|
||||
:options="inboxes || []"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
:placeholder="$t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.PICK_INBOXES')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
/>
|
||||
<p
|
||||
v-if="selectedAlertInboxes.length === 0"
|
||||
class="text-xs text-n-slate-11 mt-1"
|
||||
>
|
||||
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.NONE_WARNING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<div class="w-[50%] ltr:text-left rtl:text-right">
|
||||
<Button
|
||||
|
||||
@ -266,7 +266,6 @@ const confirmDeletion = () => {
|
||||
:email="currentAgent.email"
|
||||
:availability="currentAgent.availability_status"
|
||||
:custom-role-id="currentAgent.custom_role_id"
|
||||
:ui-settings="currentAgent.ui_settings || {}"
|
||||
@close="hideEditPopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
|
||||
@ -8,6 +8,7 @@ import UnitEdit from './units/Edit.vue';
|
||||
import GalleryIndex from './gallery/Index.vue';
|
||||
import GalleryEdit from './gallery/Edit.vue';
|
||||
const ReportsIndex = () => import('./reports/Index.vue');
|
||||
const NotificationsIndex = () => import('./notifications/Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@ -58,7 +59,7 @@ export default {
|
||||
name: 'captain_settings_gallery',
|
||||
component: GalleryIndex,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -66,7 +67,7 @@ export default {
|
||||
name: 'captain_settings_gallery_edit',
|
||||
component: GalleryEdit,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -77,6 +78,14 @@ export default {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'captain_settings_notifications',
|
||||
component: NotificationsIndex,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -0,0 +1,443 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SettingsLayout from '../../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const templates = useMapGetter('captainNotificationTemplates/getRecords');
|
||||
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
|
||||
|
||||
const selectedInboxId = ref(null);
|
||||
const editingId = ref(null);
|
||||
const showNewForm = ref(false);
|
||||
|
||||
// Todas as inboxes da conta (o usuário escolhe em qual configurar)
|
||||
const captainInboxes = computed(() => inboxes.value || []);
|
||||
|
||||
const hasInboxes = computed(() => captainInboxes.value.length > 0);
|
||||
|
||||
// ─── Formulários ──────────────────────────────────────────────────────────────
|
||||
const emptyForm = () => ({
|
||||
label: '',
|
||||
content: '',
|
||||
timing_minutes: 10,
|
||||
timing_direction: 'before',
|
||||
active: true,
|
||||
});
|
||||
|
||||
const newForm = ref(emptyForm());
|
||||
const editForm = ref(emptyForm());
|
||||
|
||||
const VARIABLES = [
|
||||
'{{guest_name}}',
|
||||
'{{check_in_time}}',
|
||||
'{{check_out_time}}',
|
||||
'{{suite_name}}',
|
||||
'{{unit_name}}',
|
||||
];
|
||||
|
||||
// ─── Carregamento ─────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
watch(selectedInboxId, async id => {
|
||||
if (id) {
|
||||
await store.dispatch('captainNotificationTemplates/fetch', id);
|
||||
showNewForm.value = false;
|
||||
editingId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Novo template ────────────────────────────────────────────────────────────
|
||||
const openNewForm = () => {
|
||||
newForm.value = emptyForm();
|
||||
showNewForm.value = true;
|
||||
};
|
||||
|
||||
const cancelNew = () => {
|
||||
showNewForm.value = false;
|
||||
newForm.value = emptyForm();
|
||||
};
|
||||
|
||||
const saveNew = async () => {
|
||||
if (!newForm.value.label || !newForm.value.content) return;
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/create', {
|
||||
inboxId: selectedInboxId.value,
|
||||
payload: newForm.value,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
|
||||
showNewForm.value = false;
|
||||
newForm.value = emptyForm();
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Edição ───────────────────────────────────────────────────────────────────
|
||||
const startEdit = template => {
|
||||
editingId.value = template.id;
|
||||
editForm.value = { ...template };
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editingId.value = null;
|
||||
editForm.value = emptyForm();
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/update', {
|
||||
inboxId: selectedInboxId.value,
|
||||
id: editingId.value,
|
||||
payload: editForm.value,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
|
||||
editingId.value = null;
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Toggle ativo ─────────────────────────────────────────────────────────────
|
||||
const toggleActive = async template => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/update', {
|
||||
inboxId: selectedInboxId.value,
|
||||
id: template.id,
|
||||
payload: { active: !template.active },
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Exclusão ─────────────────────────────────────────────────────────────────
|
||||
const deleteTemplate = async template => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/delete', {
|
||||
inboxId: selectedInboxId.value,
|
||||
id: template.id,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Variáveis ────────────────────────────────────────────────────────────────
|
||||
const insertVariable = (variable, target) => {
|
||||
if (target === 'new') {
|
||||
newForm.value.content += variable;
|
||||
} else {
|
||||
editForm.value.content += variable;
|
||||
}
|
||||
};
|
||||
|
||||
const directionLabel = direction =>
|
||||
direction === 'before'
|
||||
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE')
|
||||
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER');
|
||||
|
||||
const timingDisplay = template =>
|
||||
`${template.timing_minutes} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')} ${directionLabel(template.timing_direction)} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="t('CAPTAIN_SETTINGS.NOTIFICATIONS.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="selectedInboxId && !showNewForm"
|
||||
icon="i-lucide-plus"
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
|
||||
@click="openNewForm"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<!-- Seletor de inbox -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.INBOX_LABEL') }}
|
||||
</label>
|
||||
<div v-if="!hasInboxes" class="text-sm text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.NO_CAPTAIN_INBOXES') }}
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="inbox in captainInboxes"
|
||||
:key="inbox.id"
|
||||
class="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors"
|
||||
:class="
|
||||
selectedInboxId === inbox.id
|
||||
? 'border-w-500 bg-w-50 text-w-700 font-medium'
|
||||
: 'border-n-weak text-n-slate-11 hover:border-n-300'
|
||||
"
|
||||
@click="selectedInboxId = inbox.id"
|
||||
>
|
||||
<span class="i-lucide-message-circle w-4 h-4" />
|
||||
{{ inbox.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo: só aparece após selecionar inbox -->
|
||||
<div v-if="selectedInboxId" class="flex flex-col gap-3">
|
||||
<!-- Template list -->
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="rounded-lg border border-n-75 bg-white p-4"
|
||||
>
|
||||
<!-- View mode -->
|
||||
<div
|
||||
v-if="editingId !== template.id"
|
||||
class="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span class="text-sm font-semibold text-n-slate-12">{{
|
||||
template.label
|
||||
}}</span>
|
||||
<span class="text-sm text-n-slate-11 whitespace-pre-line">{{
|
||||
template.content
|
||||
}}</span>
|
||||
<span class="text-xs text-n-slate-10 mt-1">
|
||||
{{ timingDisplay(template) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded"
|
||||
:class="
|
||||
template.active
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-slate-3 text-n-slate-11'
|
||||
"
|
||||
@click="toggleActive(template)"
|
||||
>
|
||||
{{
|
||||
template.active
|
||||
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
|
||||
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="text-n-slate-10 hover:text-n-slate-12"
|
||||
@click="startEdit(template)"
|
||||
>
|
||||
<span class="i-lucide-pencil w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="text-n-ruby-9 hover:text-n-ruby-11"
|
||||
@click="deleteTemplate(template)"
|
||||
>
|
||||
<span class="i-lucide-trash-2 w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<input
|
||||
v-model="editForm.label"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
|
||||
"
|
||||
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editForm.content"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
|
||||
"
|
||||
rows="3"
|
||||
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
|
||||
/>
|
||||
<!-- Variable chips -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="v in VARIABLES"
|
||||
:key="v"
|
||||
class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
|
||||
@click="insertVariable(v, 'edit')"
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Timing row -->
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="editForm.timing_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
|
||||
/>
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="editForm.timing_direction"
|
||||
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
|
||||
>
|
||||
<option value="before">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
|
||||
</option>
|
||||
<option value="after">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="clear"
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
|
||||
:is-loading="uiFlags.isSaving"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New form -->
|
||||
<div
|
||||
v-if="showNewForm"
|
||||
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="newForm.label"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
|
||||
"
|
||||
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newForm.content"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
|
||||
"
|
||||
rows="3"
|
||||
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
|
||||
/>
|
||||
<!-- Variable chips -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="v in VARIABLES"
|
||||
:key="v"
|
||||
class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
|
||||
@click="insertVariable(v, 'new')"
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Timing row -->
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="newForm.timing_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
|
||||
/>
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="newForm.timing_direction"
|
||||
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
|
||||
>
|
||||
<option value="before">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
|
||||
</option>
|
||||
<option value="after">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="clear"
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
|
||||
@click="cancelNew"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
|
||||
:is-loading="uiFlags.isSaving"
|
||||
@click="saveNew"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (sem templates, sem form aberto) -->
|
||||
<div
|
||||
v-if="!templates.length && !showNewForm"
|
||||
class="flex flex-col items-center justify-center gap-4 py-16 text-center"
|
||||
>
|
||||
<div
|
||||
class="size-14 rounded-full bg-n-slate-3 flex items-center justify-center"
|
||||
>
|
||||
<span class="i-lucide-bell w-6 h-6 text-n-slate-10" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="mb-0 text-base font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.TITLE') }}
|
||||
</p>
|
||||
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-plus"
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
|
||||
@click="openNewForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado inicial: nenhuma inbox selecionada -->
|
||||
<div
|
||||
v-else-if="hasInboxes"
|
||||
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
|
||||
>
|
||||
<span class="i-lucide-mouse-pointer-click w-8 h-8 text-n-slate-9" />
|
||||
<p class="mb-0 text-sm text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.SELECT_INBOX_HINT') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@ -253,22 +253,12 @@ const fetchLpStats = async () => {
|
||||
|
||||
let pollInterval = null;
|
||||
|
||||
const insightFilterParams = () => {
|
||||
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
|
||||
return {
|
||||
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
|
||||
...(period_start && period_end && { period_start, period_end }),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInsightsForSelectedFilters = async () => {
|
||||
await store.dispatch('captainReports/fetchInsights', insightFilterParams());
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollInterval) return;
|
||||
pollInterval = setInterval(async () => {
|
||||
await fetchInsightsForSelectedFilters();
|
||||
await store.dispatch('captainReports/fetchInsights', {
|
||||
inbox_id: selectedInboxId.value,
|
||||
});
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
@ -299,7 +289,6 @@ watch(activeTab, async tab => {
|
||||
watch([customStartDate, customEndDate], async () => {
|
||||
if (selectedPeriod.value !== 'custom') return;
|
||||
if (!customStartDate.value || !customEndDate.value) return;
|
||||
await fetchInsightsForSelectedFilters();
|
||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||
if (activeTab.value === 'operational') await fetchOperational();
|
||||
if (activeTab.value === 'executive') await fetchExecutive();
|
||||
@ -319,7 +308,7 @@ watch(
|
||||
onMounted(async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
await store.dispatch('captainAssistants/get');
|
||||
await fetchInsightsForSelectedFilters();
|
||||
await store.dispatch('captainReports/fetchInsights', {});
|
||||
if (hasProcessingInsights.value) startPolling();
|
||||
await fetchLpStats();
|
||||
});
|
||||
@ -331,7 +320,9 @@ onUnmounted(() => {
|
||||
const onFilterChange = async event => {
|
||||
const value = event.target.value;
|
||||
selectedInboxId.value = value ? Number(value) : null;
|
||||
await fetchInsightsForSelectedFilters();
|
||||
await store.dispatch('captainReports/fetchInsights', {
|
||||
inbox_id: selectedInboxId.value,
|
||||
});
|
||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||
if (activeTab.value === 'operational') await fetchOperational();
|
||||
if (activeTab.value === 'executive') await fetchExecutive();
|
||||
@ -339,7 +330,6 @@ const onFilterChange = async event => {
|
||||
|
||||
const onPeriodChange = async event => {
|
||||
selectedPeriod.value = event.target.value;
|
||||
await fetchInsightsForSelectedFilters();
|
||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||
if (activeTab.value === 'operational') await fetchOperational();
|
||||
if (activeTab.value === 'executive') await fetchExecutive();
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
// Default true — só trata como false se estiver explicitamente como false.
|
||||
const isEnabled = computed({
|
||||
get() {
|
||||
return uiSettings.value?.aggressive_alert_enabled !== false;
|
||||
},
|
||||
set(value) {
|
||||
updateUISettings({ aggressive_alert_enabled: value });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-solid rounded-lg border-n-weak p-4 bg-n-solid-1 flex items-start gap-4"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-n-slate-12 mb-1">
|
||||
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 leading-normal">
|
||||
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<Switch v-model="isEnabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,233 +0,0 @@
|
||||
<script setup>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const inboxes = computed(() => getters['inboxes/getInboxes'].value || []);
|
||||
|
||||
// Modelo: ui_settings.aggressive_alert_inbox_ids
|
||||
// undefined / null → todas as inboxes (default histórico)
|
||||
// [] → desligado pra esse usuário
|
||||
// [id, id, ...] → apenas essas inboxes
|
||||
const enabled = ref(true);
|
||||
const selectedInboxIds = ref([]);
|
||||
const applyToAll = ref(true);
|
||||
|
||||
const initFromSettings = settings => {
|
||||
const raw = settings?.aggressive_alert_inbox_ids;
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) {
|
||||
enabled.value = false;
|
||||
applyToAll.value = true;
|
||||
selectedInboxIds.value = [];
|
||||
} else {
|
||||
enabled.value = true;
|
||||
applyToAll.value = false;
|
||||
selectedInboxIds.value = raw.map(id => Number(id));
|
||||
}
|
||||
} else {
|
||||
enabled.value = true;
|
||||
applyToAll.value = true;
|
||||
selectedInboxIds.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
uiSettings,
|
||||
value => {
|
||||
initFromSettings(value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const persist = async () => {
|
||||
let value;
|
||||
if (!enabled.value) {
|
||||
value = [];
|
||||
} else if (applyToAll.value) {
|
||||
value = null;
|
||||
} else {
|
||||
value = selectedInboxIds.value.map(id => Number(id));
|
||||
}
|
||||
try {
|
||||
await updateUISettings({ aggressive_alert_inbox_ids: value });
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
|
||||
} catch (e) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnabledChange = event => {
|
||||
enabled.value = event.target.checked;
|
||||
persist();
|
||||
};
|
||||
|
||||
const handleApplyToAllChange = event => {
|
||||
applyToAll.value = event.target.checked;
|
||||
if (applyToAll.value) {
|
||||
selectedInboxIds.value = [];
|
||||
}
|
||||
persist();
|
||||
};
|
||||
|
||||
const handleInboxToggle = inboxId => {
|
||||
const id = Number(inboxId);
|
||||
if (selectedInboxIds.value.includes(id)) {
|
||||
selectedInboxIds.value = selectedInboxIds.value.filter(i => i !== id);
|
||||
} else {
|
||||
selectedInboxIds.value = [...selectedInboxIds.value, id];
|
||||
}
|
||||
persist();
|
||||
};
|
||||
|
||||
const isInboxSelected = inboxId =>
|
||||
selectedInboxIds.value.includes(Number(inboxId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aggressive-alert-settings flex flex-col gap-4">
|
||||
<p class="description">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.DESCRIPTION',
|
||||
'Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<label class="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled"
|
||||
class="toggle-input"
|
||||
@change="handleEnabledChange"
|
||||
/>
|
||||
<span class="toggle-label">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.ENABLED',
|
||||
'Ativar alerta de conversa parada'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="enabled" class="scope-section">
|
||||
<label class="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="applyToAll"
|
||||
class="toggle-input"
|
||||
@change="handleApplyToAllChange"
|
||||
/>
|
||||
<span class="toggle-label">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.APPLY_TO_ALL',
|
||||
'Aplicar em todas as caixas de entrada'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!applyToAll" class="inbox-list">
|
||||
<p class="hint">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.INBOX_HINT',
|
||||
'Selecione as caixas onde você quer receber o alerta:'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<label v-for="inbox in inboxes" :key="inbox.id" class="inbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isInboxSelected(inbox.id)"
|
||||
class="toggle-input"
|
||||
@change="handleInboxToggle(inbox.id)"
|
||||
/>
|
||||
<span>{{ inbox.name }}</span>
|
||||
</label>
|
||||
<p v-if="!inboxes.length" class="empty">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NO_INBOXES',
|
||||
'Nenhuma caixa de entrada cadastrada.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.aggressive-alert-settings {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-light, #6b7280);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scope-section {
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.inbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@ -17,11 +17,9 @@ import HotKeyCard from './HotKeyCard.vue';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import NotificationPreferences from './NotificationPreferences.vue';
|
||||
import AudioNotifications from './AudioNotifications.vue';
|
||||
import AggressiveAlertSettings from './AggressiveAlertSettings.vue';
|
||||
import FormSection from 'dashboard/components/FormSection.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import AggressiveAlertProfileSetting from './AggressiveAlertProfileSetting.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import {
|
||||
ROLES,
|
||||
@ -41,10 +39,8 @@ export default {
|
||||
ChangePassword,
|
||||
NotificationPreferences,
|
||||
AudioNotifications,
|
||||
AggressiveAlertSettings,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
AggressiveAlertProfileSetting,
|
||||
},
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
@ -246,12 +242,6 @@ export default {
|
||||
@update-user="updateProfile"
|
||||
/>
|
||||
</div>
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_NOTE')"
|
||||
>
|
||||
<AggressiveAlertProfileSetting />
|
||||
</FormSection>
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
|
||||
:description="
|
||||
@ -338,22 +328,6 @@ export default {
|
||||
<AudioNotifications />
|
||||
</FormSection>
|
||||
</Policy>
|
||||
<FormSection
|
||||
:title="
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.TITLE',
|
||||
'Alerta de conversa parada'
|
||||
)
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NOTE',
|
||||
'Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<AggressiveAlertSettings />
|
||||
</FormSection>
|
||||
<Policy :permissions="notificationPermissions">
|
||||
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
|
||||
<NotificationPreferences />
|
||||
|
||||
@ -20,7 +20,6 @@ export default {
|
||||
from: 0,
|
||||
to: 0,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
inboxId: null,
|
||||
reportKeys: {
|
||||
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
|
||||
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
|
||||
@ -33,21 +32,14 @@ export default {
|
||||
return {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
inboxId: this.inboxId,
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchInboxes();
|
||||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
this.fetchBotSummary();
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchInboxes() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
},
|
||||
fetchBotSummary() {
|
||||
try {
|
||||
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
|
||||
@ -68,35 +60,24 @@ export default {
|
||||
});
|
||||
},
|
||||
getRequestPayload() {
|
||||
const { from, to, groupBy, businessHours, inboxId } = this;
|
||||
const payload = {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
};
|
||||
if (inboxId) {
|
||||
payload.type = 'inbox';
|
||||
payload.id = inboxId;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onFilterChange({ from, to, groupBy, businessHours, inboxes }) {
|
||||
onFilterChange({ from, to, groupBy, businessHours }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupBy = groupBy;
|
||||
this.businessHours = businessHours;
|
||||
this.inboxId = inboxes?.id || null;
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterValue: {
|
||||
from,
|
||||
to,
|
||||
groupBy,
|
||||
businessHours,
|
||||
inboxId: this.inboxId,
|
||||
},
|
||||
filterValue: { from, to, groupBy, businessHours },
|
||||
reportType: 'bots',
|
||||
});
|
||||
},
|
||||
@ -108,10 +89,9 @@ export default {
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilters
|
||||
filter-type="inboxes"
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
:show-business-hours="false"
|
||||
:navigate-on-entity-filter="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
|
||||
@ -1,296 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import ReportMetricCard from './components/ReportMetricCard.vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const filters = ref({ from: 0, to: 0, inboxId: null });
|
||||
const isLoadingFunnel = ref(false);
|
||||
const isLoadingBenchmark = ref(false);
|
||||
|
||||
const funnel = ref({
|
||||
leads: { total: 0, new: 0, returning: 0 },
|
||||
reservations: { created: 0, paid: 0 },
|
||||
conversion_rates: {
|
||||
lead_to_paid_reservation: 0,
|
||||
lead_to_any_reservation: 0,
|
||||
created_to_paid: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const benchmark = ref([]);
|
||||
|
||||
const fetchFunnel = () => {
|
||||
if (!filters.value.from || !filters.value.to) return;
|
||||
isLoadingFunnel.value = true;
|
||||
ReportsAPI.getConversionFunnel(filters.value)
|
||||
.then(({ data }) => {
|
||||
funnel.value = data;
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingFunnel.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchBenchmark = () => {
|
||||
if (!filters.value.from || !filters.value.to) return;
|
||||
isLoadingBenchmark.value = true;
|
||||
ReportsAPI.getInboxBenchmarking({
|
||||
from: filters.value.from,
|
||||
to: filters.value.to,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
benchmark.value = data;
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingBenchmark.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const onFilterChange = ({ from, to, inboxes }) => {
|
||||
filters.value = {
|
||||
from,
|
||||
to,
|
||||
inboxId: inboxes?.id || null,
|
||||
};
|
||||
fetchFunnel();
|
||||
fetchBenchmark();
|
||||
};
|
||||
|
||||
const benchmarkGrouped = computed(() => {
|
||||
const groups = new Map();
|
||||
benchmark.value.forEach(row => {
|
||||
const brand = row.brand_name || '— Sem marca —';
|
||||
if (!groups.has(brand)) groups.set(brand, []);
|
||||
groups.get(brand).push(row);
|
||||
});
|
||||
return [...groups.entries()].map(([brand, rows]) => {
|
||||
const totalLeads = rows.reduce((s, r) => s + r.leads_total, 0);
|
||||
const totalPaid = rows.reduce((s, r) => s + r.reservations_paid, 0);
|
||||
const brandRate = totalLeads === 0 ? 0 : (totalPaid / totalLeads) * 100;
|
||||
return { brand, rows, brandRate: Math.round(brandRate * 10) / 10 };
|
||||
});
|
||||
});
|
||||
|
||||
const formatNumber = n => Number(n || 0).toLocaleString();
|
||||
const formatPct = n => `${Number(n || 0).toFixed(1)}%`;
|
||||
|
||||
const variationFromBrand = (rowRate, brandRate) => {
|
||||
if (brandRate === 0) return null;
|
||||
return Math.round((rowRate - brandRate) * 10) / 10;
|
||||
};
|
||||
|
||||
onMounted(() => {});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('DIRECTORY_DASHBOARD.HEADER')" />
|
||||
|
||||
<div
|
||||
class="bg-n-amber-3 border border-n-amber-7 rounded-lg p-3 mb-4 text-sm text-n-slate-12"
|
||||
>
|
||||
<strong>{{ $t('DIRECTORY_DASHBOARD.BANNER.TITLE') }}</strong>
|
||||
{{ $t('DIRECTORY_DASHBOARD.BANNER.BODY') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilters
|
||||
filter-type="inboxes"
|
||||
:show-group-by="false"
|
||||
:show-business-hours="false"
|
||||
:navigate-on-entity-filter="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
|
||||
>
|
||||
<h3 class="text-base font-semibold m-0 mb-4">
|
||||
{{ $t('DIRECTORY_DASHBOARD.HEADLINE_NUMBERS') }}
|
||||
</h3>
|
||||
<Spinner v-if="isLoadingFunnel" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<ReportMetricCard
|
||||
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.LABEL')"
|
||||
:value="formatNumber(funnel.leads.total)"
|
||||
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.TOOLTIP')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.LABEL')"
|
||||
:value="formatNumber(funnel.leads.new)"
|
||||
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.TOOLTIP')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.LABEL')"
|
||||
:value="formatNumber(funnel.leads.returning)"
|
||||
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.TOOLTIP')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.LABEL')"
|
||||
:value="formatPct(funnel.conversion_rates.lead_to_paid_reservation)"
|
||||
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.TOOLTIP')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
|
||||
>
|
||||
<h3 class="text-base font-semibold m-0 mb-4">
|
||||
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.TITLE') }}
|
||||
</h3>
|
||||
<Spinner v-if="isLoadingFunnel" />
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-32 text-sm text-n-slate-11">
|
||||
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_LEADS') }}
|
||||
</div>
|
||||
<div class="flex-1 bg-n-blue-9 text-white px-4 py-3 rounded-lg">
|
||||
<span class="text-lg font-semibold">{{
|
||||
formatNumber(funnel.leads.total)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="w-24 text-sm text-n-slate-11 text-right">
|
||||
{{ formatPct(100) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-32 text-sm text-n-slate-11">
|
||||
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_RESERVATIONS') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 bg-n-blue-7 text-white px-4 py-3 rounded-lg"
|
||||
:style="{
|
||||
maxWidth:
|
||||
funnel.leads.total === 0
|
||||
? '100%'
|
||||
: `${Math.max(20, (funnel.reservations.created / Math.max(1, funnel.leads.total)) * 100)}%`,
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{
|
||||
formatNumber(funnel.reservations.created)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="w-24 text-sm text-n-slate-11 text-right">
|
||||
{{ formatPct(funnel.conversion_rates.lead_to_any_reservation) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-32 text-sm text-n-slate-11">
|
||||
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_PAID') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 bg-n-teal-9 text-white px-4 py-3 rounded-lg"
|
||||
:style="{
|
||||
maxWidth:
|
||||
funnel.leads.total === 0
|
||||
? '100%'
|
||||
: `${Math.max(20, (funnel.reservations.paid / Math.max(1, funnel.leads.total)) * 100)}%`,
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{
|
||||
formatNumber(funnel.reservations.paid)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="w-24 text-sm text-n-slate-11 text-right">
|
||||
{{ formatPct(funnel.conversion_rates.lead_to_paid_reservation) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
|
||||
>
|
||||
<h3 class="text-base font-semibold m-0 mb-4">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.TITLE') }}
|
||||
</h3>
|
||||
<Spinner v-if="isLoadingBenchmark" />
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-n-slate-11 border-b border-n-weak">
|
||||
<th class="py-2 pr-4">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_INBOX') }}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_LEADS') }}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_CREATED') }}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_PAID') }}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_RATE') }}
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_VS_BRAND') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<template v-for="group in benchmarkGrouped" :key="group.brand">
|
||||
<tbody>
|
||||
<tr class="bg-n-slate-3">
|
||||
<td colspan="6" class="py-2 px-2 font-semibold text-n-slate-12">
|
||||
{{ group.brand }}
|
||||
<span class="text-n-slate-11 font-normal text-xs ms-2">
|
||||
({{ $t('DIRECTORY_DASHBOARD.BENCHMARK.BRAND_AVG') }}
|
||||
{{ formatPct(group.brandRate) }})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in group.rows"
|
||||
:key="row.inbox_id"
|
||||
class="border-b border-n-weak"
|
||||
>
|
||||
<td class="py-2 pr-4 text-n-slate-12">{{ row.inbox_name }}</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
{{ formatNumber(row.leads_total) }}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
{{ formatNumber(row.reservations_created) }}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
{{ formatNumber(row.reservations_paid) }}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right font-medium">
|
||||
{{ formatPct(row.conversion_rate) }}
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
<span
|
||||
v-if="
|
||||
variationFromBrand(row.conversion_rate, group.brandRate) >
|
||||
0
|
||||
"
|
||||
class="text-n-teal-11"
|
||||
>
|
||||
+{{
|
||||
variationFromBrand(row.conversion_rate, group.brandRate)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
variationFromBrand(row.conversion_rate, group.brandRate) <
|
||||
0
|
||||
"
|
||||
class="text-n-ruby-11"
|
||||
>
|
||||
{{
|
||||
variationFromBrand(row.conversion_rate, group.brandRate)
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-n-slate-11">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,73 +1,26 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import InboxLeadsReport from './components/InboxLeadsReport.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const inboxIdParam = computed(() => route.params.id);
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', inboxIdParam);
|
||||
|
||||
const TABS = {
|
||||
OVERVIEW: 'overview',
|
||||
LEADS: 'leads',
|
||||
};
|
||||
|
||||
const activeTab = ref(TABS.OVERVIEW);
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="inbox.id" class="flex flex-col w-full">
|
||||
<div class="flex items-center gap-6 px-6 pt-4 border-b border-n-weak">
|
||||
<button
|
||||
type="button"
|
||||
class="py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="
|
||||
activeTab === TABS.OVERVIEW
|
||||
? 'border-n-brand text-n-brand'
|
||||
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = TABS.OVERVIEW"
|
||||
>
|
||||
{{ $t('INBOX_REPORTS.TABS.OVERVIEW') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="
|
||||
activeTab === TABS.LEADS
|
||||
? 'border-n-brand text-n-brand'
|
||||
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = TABS.LEADS"
|
||||
>
|
||||
{{ $t('INBOX_REPORTS.TABS.LEADS') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<WootReports
|
||||
v-if="activeTab === TABS.OVERVIEW"
|
||||
:key="`overview-${inbox.id}`"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:selected-item="inbox"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<InboxLeadsReport
|
||||
v-else-if="activeTab === TABS.LEADS"
|
||||
:key="`leads-${inbox.id}`"
|
||||
:inbox-id="inbox.id"
|
||||
:inbox-name="inbox.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<WootReports
|
||||
v-if="inbox.id"
|
||||
:key="inbox.id"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:selected-item="inbox"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
|
||||
@ -12,11 +12,8 @@ const props = defineProps({
|
||||
|
||||
const conversationCount = ref('0');
|
||||
const messageCount = ref('0');
|
||||
const botResolutionRate = ref('0');
|
||||
const humanTransferRate = ref('0');
|
||||
const botResolutionsCount = ref('0');
|
||||
const autoHandoffsCount = ref('0');
|
||||
const manualTakeoversCount = ref('0');
|
||||
const resolutionRate = ref('0');
|
||||
const handoffRate = ref('0');
|
||||
|
||||
const formatToPercent = value => {
|
||||
return value ? `${value}%` : '--';
|
||||
@ -29,17 +26,8 @@ const fetchMetrics = () => {
|
||||
ReportsAPI.getBotMetrics(props.filters).then(response => {
|
||||
conversationCount.value = response.data.conversation_count.toLocaleString();
|
||||
messageCount.value = response.data.message_count.toLocaleString();
|
||||
botResolutionRate.value = response.data.resolution_rate.toString();
|
||||
humanTransferRate.value = response.data.handoff_rate.toString();
|
||||
botResolutionsCount.value = (
|
||||
response.data.bot_resolutions_count || 0
|
||||
).toLocaleString();
|
||||
autoHandoffsCount.value = (
|
||||
response.data.auto_handoffs_count || 0
|
||||
).toLocaleString();
|
||||
manualTakeoversCount.value = (
|
||||
response.data.manual_takeovers_count || 0
|
||||
).toLocaleString();
|
||||
resolutionRate.value = response.data.resolution_rate.toString();
|
||||
handoffRate.value = response.data.handoff_rate.toString();
|
||||
});
|
||||
};
|
||||
|
||||
@ -49,57 +37,32 @@ onMounted(fetchMetrics);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
|
||||
:value="conversationCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="messageCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(botResolutionRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(humanTransferRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.TOOLTIP')"
|
||||
:value="botResolutionsCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.TOOLTIP')"
|
||||
:value="autoHandoffsCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.TOOLTIP')"
|
||||
:value="manualTakeoversCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
|
||||
:value="conversationCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="messageCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(resolutionRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(handoffRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format } from 'date-fns';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportMetricCard from './ReportMetricCard.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const filters = ref({
|
||||
from: null,
|
||||
to: null,
|
||||
groupBy: { id: 1, period: 'day' },
|
||||
});
|
||||
|
||||
const isFetching = computed(() => store.getters.getInboxLeadsSummaryFetching);
|
||||
const rows = computed(() => store.getters.getInboxLeadsSummary || []);
|
||||
|
||||
const totals = computed(() => {
|
||||
return rows.value.reduce(
|
||||
(acc, row) => {
|
||||
acc.new_leads += row.new_leads || 0;
|
||||
acc.returning += row.returning || 0;
|
||||
acc.others += row.others || 0;
|
||||
return acc;
|
||||
},
|
||||
{ new_leads: 0, returning: 0, others: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
const totalConversations = computed(
|
||||
() => totals.value.new_leads + totals.value.returning + totals.value.others
|
||||
);
|
||||
|
||||
const formatPeriodLabel = (iso, period) => {
|
||||
const date = new Date(iso);
|
||||
if (period === 'month') return format(date, 'MMM/yy');
|
||||
if (period === 'week') return format(date, "'S'II/yy");
|
||||
return format(date, 'dd/MM');
|
||||
};
|
||||
|
||||
const chartCollection = computed(() => {
|
||||
const period = filters.value.groupBy?.period || 'day';
|
||||
return {
|
||||
labels: rows.value.map(r => formatPeriodLabel(r.period, period)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.NEW_LEADS'),
|
||||
backgroundColor: '#10B981',
|
||||
data: rows.value.map(r => r.new_leads),
|
||||
},
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.RETURNING'),
|
||||
backgroundColor: '#3B82F6',
|
||||
data: rows.value.map(r => r.returning),
|
||||
},
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.OTHERS'),
|
||||
backgroundColor: '#9CA3AF',
|
||||
data: rows.value.map(r => r.others),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = {
|
||||
plugins: {
|
||||
legend: { display: true, position: 'bottom' },
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { drawOnChartArea: false } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } },
|
||||
},
|
||||
};
|
||||
|
||||
const fetchData = () => {
|
||||
if (!filters.value.from || !filters.value.to || !props.inboxId) return;
|
||||
store.dispatch('fetchInboxLeadsSummary', {
|
||||
inboxId: props.inboxId,
|
||||
from: filters.value.from,
|
||||
to: filters.value.to,
|
||||
groupBy: filters.value.groupBy?.period || 'day',
|
||||
});
|
||||
};
|
||||
|
||||
const onFilterChange = payload => {
|
||||
filters.value = {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
groupBy: payload.groupBy || filters.value.groupBy,
|
||||
};
|
||||
fetchData();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.inboxId,
|
||||
() => fetchData()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-n-slate-12 m-0">
|
||||
{{ $t('INBOX_REPORTS.LEADS.TITLE') }}
|
||||
</h2>
|
||||
<p v-if="inboxName" class="text-sm text-n-slate-11 mt-1 mb-0">
|
||||
<span>{{ $t('INBOX_REPORTS.LEADS.INBOX_LABEL') }}</span>
|
||||
<span class="font-medium text-n-slate-12 ms-1">{{ inboxName }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportFilters
|
||||
filter-type="inboxes"
|
||||
:selected-item="{ id: Number(inboxId) }"
|
||||
:show-business-hours="false"
|
||||
:show-entity-filter="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.LABEL')"
|
||||
:value="String(totals.new_leads)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.INFO')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.LABEL')"
|
||||
:value="String(totals.returning)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.INFO')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.LABEL')"
|
||||
:value="String(totals.others)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.INFO')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-1 border border-n-weak rounded-lg p-4 min-h-[320px] flex items-center justify-center"
|
||||
>
|
||||
<Spinner v-if="isFetching" />
|
||||
<div v-else-if="rows.length === 0" class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_REPORTS.LEADS.EMPTY') }}
|
||||
</div>
|
||||
<div v-else class="w-full h-[320px]">
|
||||
<BarChart :collection="chartCollection" :chart-options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_REPORTS.LEADS.TOTAL', { count: totalConversations }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -12,9 +12,7 @@ import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
import {
|
||||
generateFilterURLParams,
|
||||
generateReportURLParams,
|
||||
parseFilterURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
|
||||
@ -42,10 +40,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
navigateOnEntityFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
@ -182,11 +176,8 @@ const updateURLParams = () => {
|
||||
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
const filterParams = props.showEntityFilter
|
||||
? generateFilterURLParams(appliedFilters.value)
|
||||
: {};
|
||||
|
||||
router.replace({ query: { ...params, ...filterParams } });
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
@ -244,10 +235,6 @@ const addFilter = item => {
|
||||
agents: 'agent_reports_show',
|
||||
};
|
||||
|
||||
if (!props.navigateOnEntityFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const routeName = routeNameMap[props.filterType];
|
||||
if (routeName) {
|
||||
router.push({
|
||||
@ -321,12 +308,6 @@ const initializeFromURL = () => {
|
||||
if (props.showEntityFilter && route.params.id) {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = Number(route.params.id);
|
||||
} else if (props.showEntityFilter) {
|
||||
const filterKey = getFilterKey();
|
||||
const filterParams = parseFilterURLParams(route.query);
|
||||
if (filterParams[filterKey]) {
|
||||
appliedFilters.value[filterKey] = filterParams[filterKey];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -23,7 +23,6 @@ import CsatResponses from './CsatResponses.vue';
|
||||
import BotReports from './BotReports.vue';
|
||||
import LiveReports from './LiveReports.vue';
|
||||
import SLAReports from './SLAReports.vue';
|
||||
import DirectoryDashboard from './DirectoryDashboard.vue';
|
||||
|
||||
const meta = {
|
||||
featureFlag: FEATURE_FLAGS.REPORTS,
|
||||
@ -169,12 +168,6 @@ export default {
|
||||
meta,
|
||||
component: BotReports,
|
||||
},
|
||||
{
|
||||
path: 'directory_dashboard',
|
||||
name: 'directory_dashboard_reports',
|
||||
meta,
|
||||
component: DirectoryDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import notificationTemplatesAPI from '../../api/captain/notificationTemplates';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isSaving: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getRecords: $state => $state.records,
|
||||
getUIFlags: $state => $state.uiFlags,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
async fetch({ commit }, inboxId) {
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const { data } = await notificationTemplatesAPI.getAll(inboxId);
|
||||
commit('SET_RECORDS', data);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
async create({ commit }, { inboxId, payload }) {
|
||||
commit('SET_UI_FLAG', { isSaving: true });
|
||||
try {
|
||||
const { data } = await notificationTemplatesAPI.create(inboxId, payload);
|
||||
commit('ADD_RECORD', data);
|
||||
return data;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
async update({ commit }, { inboxId, id, payload }) {
|
||||
commit('SET_UI_FLAG', { isSaving: true });
|
||||
try {
|
||||
const { data } = await notificationTemplatesAPI.update(
|
||||
inboxId,
|
||||
id,
|
||||
payload
|
||||
);
|
||||
commit('UPDATE_RECORD', data);
|
||||
return data;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete({ commit }, { inboxId, id }) {
|
||||
await notificationTemplatesAPI.delete(inboxId, id);
|
||||
commit('DELETE_RECORD', id);
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_RECORDS($state, records) {
|
||||
$state.records = records;
|
||||
},
|
||||
ADD_RECORD($state, record) {
|
||||
$state.records.push(record);
|
||||
},
|
||||
UPDATE_RECORD($state, record) {
|
||||
const idx = $state.records.findIndex(r => r.id === record.id);
|
||||
if (idx !== -1) $state.records.splice(idx, 1, record);
|
||||
},
|
||||
DELETE_RECORD($state, id) {
|
||||
$state.records = $state.records.filter(r => r.id !== id);
|
||||
},
|
||||
SET_UI_FLAG($state, flags) {
|
||||
$state.uiFlags = { ...$state.uiFlags, ...flags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@ -5,16 +5,6 @@ import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
export default createStore({
|
||||
name: 'CaptainReservation',
|
||||
API: CaptainReservationsAPI,
|
||||
mutations: {
|
||||
SET_CAPTAINRESERVATION_META(state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
statusCounts: meta.status_counts || meta.statusCounts || {},
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
fetchRevenue: async function fetchRevenue(_, params = {}) {
|
||||
try {
|
||||
|
||||
@ -66,6 +66,7 @@ import captainLifecycleDeliveries from './captain/lifecycleDeliveries';
|
||||
import captainUnits from './modules/captainUnits';
|
||||
import captainGalleryItems from './modules/captainGalleryItems';
|
||||
import captainReports from './modules/captainReports';
|
||||
import captainNotificationTemplates from './captain/notificationTemplates';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
@ -137,6 +138,7 @@ export default createStore({
|
||||
captainUnits,
|
||||
captainGalleryItems,
|
||||
captainReports,
|
||||
captainNotificationTemplates,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@ -67,10 +67,6 @@ const state = {
|
||||
agentConversationMetric: [],
|
||||
teamConversationMetric: [],
|
||||
},
|
||||
inboxLeadsSummary: {
|
||||
isFetching: false,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
@ -107,12 +103,6 @@ const getters = {
|
||||
getOverviewUIFlags($state) {
|
||||
return $state.overview.uiFlags;
|
||||
},
|
||||
getInboxLeadsSummary(_state) {
|
||||
return _state.inboxLeadsSummary.data;
|
||||
},
|
||||
getInboxLeadsSummaryFetching(_state) {
|
||||
return _state.inboxLeadsSummary.isFetching;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@ -180,8 +170,6 @@ export const actions = {
|
||||
to: reportObj.to,
|
||||
groupBy: reportObj.groupBy,
|
||||
businessHours: reportObj.businessHours,
|
||||
type: reportObj.type,
|
||||
id: reportObj.id,
|
||||
})
|
||||
.then(botSummary => {
|
||||
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
||||
@ -298,20 +286,6 @@ export const actions = {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
fetchInboxLeadsSummary({ commit }, reportObj) {
|
||||
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, true);
|
||||
return Report.getInboxLeadsSummary(reportObj)
|
||||
.then(response => {
|
||||
commit(types.default.SET_INBOX_LEADS_SUMMARY, response.data || []);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
commit(types.default.SET_INBOX_LEADS_SUMMARY, []);
|
||||
})
|
||||
.finally(() => {
|
||||
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, false);
|
||||
});
|
||||
},
|
||||
downloadAccountConversationHeatmap(_, reportObj) {
|
||||
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||
.then(response => {
|
||||
@ -383,12 +357,6 @@ const mutations = {
|
||||
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
|
||||
},
|
||||
[types.default.SET_INBOX_LEADS_SUMMARY](_state, data) {
|
||||
_state.inboxLeadsSummary.data = data;
|
||||
},
|
||||
[types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING](_state, flag) {
|
||||
_state.inboxLeadsSummary.isFetching = flag;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@ -209,8 +209,6 @@ export default {
|
||||
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
|
||||
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
|
||||
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
|
||||
SET_INBOX_LEADS_SUMMARY: 'SET_INBOX_LEADS_SUMMARY',
|
||||
TOGGLE_INBOX_LEADS_SUMMARY_LOADING: 'TOGGLE_INBOX_LEADS_SUMMARY_LOADING',
|
||||
|
||||
// Conversation Metadata
|
||||
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
|
||||
|
||||
@ -13,6 +13,4 @@ export const BUS_EVENTS = {
|
||||
NEW_CONVERSATION_MODAL: 'newConversationModal',
|
||||
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
|
||||
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
|
||||
AGGRESSIVE_ALERT_TRIGGER: 'AGGRESSIVE_ALERT_TRIGGER',
|
||||
AGGRESSIVE_ALERT_DISMISS: 'AGGRESSIVE_ALERT_DISMISS',
|
||||
};
|
||||
|
||||
@ -2,30 +2,6 @@ class DeleteObjectJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 5_000
|
||||
INBOX_DEPENDENT_TABLES = %i[
|
||||
captain_feedback_logs
|
||||
captain_lifecycle_deliveries
|
||||
captain_reminders
|
||||
captain_reservations
|
||||
captain_gallery_items
|
||||
captain_inbox_automations
|
||||
captain_inbox_reminder_settings
|
||||
captain_inboxes
|
||||
captain_notification_templates
|
||||
captain_pricing_inboxes
|
||||
captain_tool_configs
|
||||
captain_unit_inboxes
|
||||
jasmine_inbox_collections
|
||||
jasmine_inbox_settings
|
||||
jasmine_tool_configs
|
||||
].freeze
|
||||
INBOX_NULLIFY_TARGETS = [
|
||||
[:captain_conversation_insights, :inbox_id],
|
||||
[:captain_pricings, :inbox_id],
|
||||
[:jasmine_collections, :owner_inbox_id],
|
||||
[:captain_units, :inbox_id],
|
||||
[:captain_units, :concierge_inbox_id]
|
||||
].freeze
|
||||
|
||||
def perform(object, user = nil, ip = nil)
|
||||
# Pre-purge heavy associations for large objects to avoid
|
||||
@ -47,8 +23,6 @@ class DeleteObjectJob < ApplicationJob
|
||||
end
|
||||
|
||||
def purge_heavy_associations(object)
|
||||
purge_inbox_blocking_associations(object) if object.is_a?(Inbox)
|
||||
|
||||
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
|
||||
return unless klass
|
||||
|
||||
@ -64,71 +38,6 @@ class DeleteObjectJob < ApplicationJob
|
||||
batch.each(&:destroy!)
|
||||
end
|
||||
end
|
||||
|
||||
def purge_inbox_blocking_associations(inbox)
|
||||
inbox_id = inbox.id
|
||||
reservation_ids = select_ids(:captain_reservations, :inbox_id, inbox_id)
|
||||
|
||||
purge_reservation_children(reservation_ids)
|
||||
|
||||
# fazer.ai/Captain tables hold hard FKs to inboxes. If these survive, the
|
||||
# async delete job fails and the UI shows the inbox again on refresh.
|
||||
INBOX_DEPENDENT_TABLES.each { |table| delete_by_column(table, :inbox_id, inbox_id) }
|
||||
nullify_inbox_references(inbox_id)
|
||||
end
|
||||
|
||||
def purge_reservation_children(reservation_ids)
|
||||
delete_where_in(:captain_lifecycle_deliveries, :captain_reservation_id, reservation_ids)
|
||||
delete_where_in(:captain_pix_charges, :reservation_id, reservation_ids)
|
||||
end
|
||||
|
||||
def nullify_inbox_references(inbox_id)
|
||||
INBOX_NULLIFY_TARGETS.each do |table, column|
|
||||
nullify_by_column(table, column, inbox_id)
|
||||
end
|
||||
end
|
||||
|
||||
def select_ids(table, column, value)
|
||||
return [] unless column_available?(table, column)
|
||||
|
||||
sql = "SELECT id FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}"
|
||||
db.select_values(sql).map(&:to_i)
|
||||
end
|
||||
|
||||
def delete_where_in(table, column, values)
|
||||
return if values.blank?
|
||||
return unless column_available?(table, column)
|
||||
|
||||
db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} IN (#{values.map { |v| Integer(v) }.join(',')})")
|
||||
end
|
||||
|
||||
def delete_by_column(table, column, value)
|
||||
return unless column_available?(table, column)
|
||||
|
||||
db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}")
|
||||
end
|
||||
|
||||
def nullify_by_column(table, column, value)
|
||||
return unless column_available?(table, column)
|
||||
|
||||
db.execute("UPDATE #{quote_table(table)} SET #{quote_column(column)} = NULL WHERE #{quote_column(column)} = #{Integer(value)}")
|
||||
end
|
||||
|
||||
def column_available?(table, column)
|
||||
db.data_source_exists?(table.to_s) && db.column_exists?(table.to_s, column.to_s)
|
||||
end
|
||||
|
||||
def quote_table(table)
|
||||
db.quote_table_name(table)
|
||||
end
|
||||
|
||||
def quote_column(column)
|
||||
db.quote_column_name(column)
|
||||
end
|
||||
|
||||
def db
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
end
|
||||
|
||||
DeleteObjectJob.prepend_mod_with('DeleteObjectJob')
|
||||
|
||||
@ -133,22 +133,11 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is human interaction on the conversation.
|
||||
# Human interaction = outgoing message either from a User (replied via Chatwoot UI) OR from a
|
||||
# nil sender (replied directly via the connected WhatsApp app — webhook echo with IsFromMe=true,
|
||||
# see app/services/whatsapp/incoming_message_wuzapi_service.rb#build_message).
|
||||
# The bot itself uses sender_type 'Captain::Assistant' (or 'AgentBot'), so it stays excluded from this filter.
|
||||
return if human_outgoing_messages?(conversation)
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
return if conversation.messages.exists?(message_type: :outgoing, sender_type: 'User')
|
||||
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
end
|
||||
|
||||
def human_outgoing_messages?(conversation)
|
||||
conversation.messages
|
||||
.where(message_type: :outgoing)
|
||||
.where('sender_type = ? OR sender_type IS NULL', 'User')
|
||||
.any?
|
||||
end
|
||||
end
|
||||
|
||||
@ -90,7 +90,6 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :aggressive_alert_enabled
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# concierge_config :jsonb not null
|
||||
# currency :string default("BRL"), not null
|
||||
# extra_person_fee :decimal(10, 2) default(0.0), not null
|
||||
# inter_account_number :string
|
||||
# inter_cert_content :text
|
||||
# inter_cert_path :string
|
||||
@ -34,9 +32,6 @@
|
||||
# inbox_id :bigint
|
||||
# inter_client_id :string
|
||||
# plug_play_id :string
|
||||
# supabase_marca_id :uuid
|
||||
# supabase_tenant_id :bigint default(1)
|
||||
# supabase_unit_id :uuid
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@ -44,7 +39,6 @@
|
||||
# index_captain_units_on_captain_brand_id (captain_brand_id)
|
||||
# index_captain_units_on_concierge_inbox_id (concierge_inbox_id)
|
||||
# index_captain_units_on_inbox_id (inbox_id)
|
||||
# index_captain_units_on_supabase_unit_id (supabase_unit_id) UNIQUE WHERE (supabase_unit_id IS NOT NULL)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@ -77,7 +77,6 @@ class Inbox < ApplicationRecord
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, dependent: :destroy_async
|
||||
has_many :scheduled_messages, dependent: :destroy_async
|
||||
has_many :reporting_events, dependent: :nullify
|
||||
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
@ -14,7 +14,7 @@ class Conversations::MessageWindowService
|
||||
|
||||
private
|
||||
|
||||
def messaging_window
|
||||
def messaging_window # rubocop:disable Metrics/CyclomaticComplexity
|
||||
case @conversation.inbox.channel_type
|
||||
when 'Channel::Api'
|
||||
api_messaging_window
|
||||
@ -25,10 +25,7 @@ class Conversations::MessageWindowService
|
||||
when 'Channel::Tiktok'
|
||||
tiktok_messaging_window
|
||||
when 'Channel::Whatsapp'
|
||||
# Providers via WhatsApp Web (baileys, zapi, wuzapi, evolution) não
|
||||
# estão sujeitos à janela de 24h da Meta Cloud API — Web permite
|
||||
# mensagem livre a qualquer momento.
|
||||
return if %w[baileys zapi wuzapi evolution].include?(@conversation.inbox.channel.provider)
|
||||
return if %w[baileys zapi].include?(@conversation.inbox.channel.provider)
|
||||
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
|
||||
@ -158,9 +158,9 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
|
||||
# Se a mensagem vier do celular (outgoing) e a assinatura estiver ativa,
|
||||
# e o conteúdo não parecer já ter uma assinatura (evita duplicar em ecos)
|
||||
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*')
|
||||
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*[') && !content.start_with?('*')
|
||||
signature_name = inbox_obj.shift_signature_name
|
||||
content = "*#{signature_name}*\n#{content}" if signature_name.present?
|
||||
content = "*[ #{signature_name} ]*\n#{content}" if signature_name.present?
|
||||
end
|
||||
|
||||
msg_params = {
|
||||
|
||||
@ -26,14 +26,11 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
|
||||
caption = content_with_signature || message.content
|
||||
|
||||
base64_data = attachment.file.blob.open { |tmp| Base64.strict_encode64(tmp.read) }
|
||||
data_uri = "data:#{mime_type};base64,#{base64_data}"
|
||||
|
||||
if mime_type.start_with?('image/')
|
||||
data_uri = "data:#{mime_type};base64,#{base64_data}"
|
||||
client.send_image(user_token, phone_number, data_uri, caption)
|
||||
else
|
||||
# Wuzapi `/chat/send/document` exige prefixo `application/octet-stream`
|
||||
# no data URI; o tipo real é inferido pelo FileName.
|
||||
data_uri = "data:application/octet-stream;base64,#{base64_data}"
|
||||
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
|
||||
end
|
||||
end
|
||||
@ -178,7 +175,7 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
|
||||
return content unless message.inbox.message_signature_enabled?
|
||||
|
||||
name = sender_name_for(message)
|
||||
name.present? ? "*#{name}*\n#{content}" : content
|
||||
name.present? ? "*[ #{name} ]*\n#{content}" : content
|
||||
end
|
||||
|
||||
def reply_params(message)
|
||||
|
||||
@ -56,22 +56,13 @@ class Wuzapi::Client # rubocop:disable Metrics/ClassLength
|
||||
end
|
||||
|
||||
def send_file(user_token, phone_number, base64_data, filename)
|
||||
# Wuzapi (asternic) `/chat/send/document` espera o campo `Document`
|
||||
# (data URI base64). `Body`/`Filename` ficam só pra fallback de versões
|
||||
# mais antigas que aceitavam isso.
|
||||
payload = {
|
||||
'Phone' => phone_number,
|
||||
'Document' => base64_data,
|
||||
'FileName' => filename,
|
||||
'Body' => base64_data,
|
||||
'Filename' => filename
|
||||
}
|
||||
payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename }
|
||||
request(
|
||||
:post,
|
||||
'/chat/send/document',
|
||||
'/chat/send/file',
|
||||
payload,
|
||||
user_auth_headers(user_token),
|
||||
fallback_paths: ['/send/document', '/chat/send/file', '/send/file'],
|
||||
fallback_paths: ['/send/file'],
|
||||
allow_base_fallback: true
|
||||
)
|
||||
end
|
||||
@ -233,9 +224,7 @@ class Wuzapi::Client # rubocop:disable Metrics/ClassLength
|
||||
|
||||
begin
|
||||
http.request(request_obj)
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
# ECONNRESET surge quando proxy intermediário corta antes do servidor
|
||||
# responder — tratar como ConnectionError pra ativar fallback de path/base.
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
raise ConnectionError, "Could not connect to Wuzapi: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@ -11,5 +11,4 @@ json.custom_attributes resource.custom_attributes if resource.custom_attributes.
|
||||
json.name resource.name
|
||||
json.role resource.role
|
||||
json.thumbnail resource.avatar_url
|
||||
json.ui_settings resource.ui_settings
|
||||
json.custom_role_id resource.current_account_user&.custom_role_id if ChatwootApp.enterprise?
|
||||
|
||||
@ -1,401 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# hermes-provision — provisiona um novo agente Hermes ponta-a-ponta.
|
||||
#
|
||||
# Uso:
|
||||
# hermes-provision [--dry-run] [--rollback <slug>] < spec.json
|
||||
#
|
||||
# Spec JSON esperado (stdin):
|
||||
# {
|
||||
# "slug": "lara",
|
||||
# "name": "Lara",
|
||||
# "account_id": 1,
|
||||
# "marca": "Hotel 1001 Noites Prime",
|
||||
# "unit_name": "PrimeVL",
|
||||
# "city": "Brasília/DF",
|
||||
# "captain_unit_id": null, // opcional — se null, cria nova Unit
|
||||
# "parent_assistant_id": null, // opcional — se setado, MCP usa data do parent
|
||||
# "soul_md": "<conteúdo SOUL.md inteiro>",
|
||||
# "skill_name": "primevl-reservas",
|
||||
# "skill_md": "<conteúdo SKILL.md>",
|
||||
# "categories": [
|
||||
# {
|
||||
# "key": "standard",
|
||||
# "aliases": ["standard", "comum"],
|
||||
# "extra_person_starts_at": 3,
|
||||
# "amounts": [
|
||||
# {"period": "3h", "day_bucket": null, "amount": 50.0},
|
||||
# {"period": "pernoite_promo", "day_bucket": "mon_wed", "amount": 100.0}
|
||||
# ]
|
||||
# }
|
||||
# ],
|
||||
# "extra_person_fee": 0.0,
|
||||
# "humanization": {
|
||||
# "mode": "typing_simulation",
|
||||
# "chars_per_second": 25,
|
||||
# "min_seconds": 1.5,
|
||||
# "max_seconds": 6.0
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Saída: JSON com {ok, assistant_id, port, secret, errors}
|
||||
#
|
||||
# Quem chama: Construtor Hermes via terminal skill nativa.
|
||||
# Pré-requisitos na VPS: jq, openssl, docker, systemctl, git, hermes binary.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROFILES_DIR="/root/.hermes/profiles"
|
||||
TEMPLATE_PROFILE="$PROFILES_DIR/valentina"
|
||||
PORT_RANGE_START=8650
|
||||
PORT_RANGE_END=8699
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
GIT_BACKUP_REPO="/root/hermes_profiles_backup"
|
||||
DOCKER_APP_FILTER="iachat_iachat_app"
|
||||
|
||||
DRY_RUN=0
|
||||
ROLLBACK_SLUG=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--rollback) ROLLBACK_SLUG="$2"; shift 2 ;;
|
||||
*) echo "{\"ok\":false,\"error\":\"unknown flag: $1\"}" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo "[$(date -u +%H:%M:%S)] $*" >&2; }
|
||||
fail() { echo "{\"ok\":false,\"error\":\"$1\"}"; exit 1; }
|
||||
require_cmd() { command -v "$1" >/dev/null || fail "missing required command: $1"; }
|
||||
|
||||
require_cmd jq
|
||||
require_cmd openssl
|
||||
require_cmd docker
|
||||
require_cmd systemctl
|
||||
require_cmd hermes
|
||||
|
||||
# === ROLLBACK ===
|
||||
if [[ -n "$ROLLBACK_SLUG" ]]; then
|
||||
log "rolling back $ROLLBACK_SLUG"
|
||||
systemctl stop "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
|
||||
systemctl disable "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
|
||||
rm -rf "${PROFILES_DIR:?}/$ROLLBACK_SLUG"
|
||||
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
|
||||
docker exec "$CID" bundle exec rails runner "
|
||||
asst = Captain::Assistant.find_by(hermes_profile_name: '$ROLLBACK_SLUG')
|
||||
if asst
|
||||
asst.captain_inboxes.destroy_all
|
||||
asst.destroy!
|
||||
puts 'destroyed assistant ' + asst.id.to_s
|
||||
else
|
||||
puts 'no assistant for slug $ROLLBACK_SLUG'
|
||||
end
|
||||
" 2>&1 | grep -v RubyLLM | tail -3
|
||||
echo "{\"ok\":true,\"action\":\"rollback\",\"slug\":\"$ROLLBACK_SLUG\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# === READ + VALIDATE SPEC ===
|
||||
SPEC=$(cat)
|
||||
[[ -z "$SPEC" ]] && fail "empty spec on stdin"
|
||||
echo "$SPEC" | jq empty 2>/dev/null || fail "invalid JSON"
|
||||
|
||||
SLUG=$(echo "$SPEC" | jq -r '.slug // empty')
|
||||
NAME=$(echo "$SPEC" | jq -r '.name // empty')
|
||||
ACCOUNT_ID=$(echo "$SPEC" | jq -r '.account_id // empty')
|
||||
MARCA=$(echo "$SPEC" | jq -r '.marca // empty')
|
||||
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name // empty')
|
||||
CAPTAIN_UNIT_ID=$(echo "$SPEC" | jq -r '.captain_unit_id // empty')
|
||||
PARENT_ASSISTANT_ID=$(echo "$SPEC" | jq -r '.parent_assistant_id // empty')
|
||||
SOUL_MD=$(echo "$SPEC" | jq -r '.soul_md // empty')
|
||||
CITY=$(echo "$SPEC" | jq -r '.city // ""')
|
||||
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name // empty')
|
||||
SKILL_MD=$(echo "$SPEC" | jq -r '.skill_md // empty')
|
||||
EXTRA_PERSON_FEE=$(echo "$SPEC" | jq -r '.extra_person_fee // 0')
|
||||
|
||||
# Slug validation
|
||||
[[ ! "$SLUG" =~ ^[a-z][a-z0-9_]{1,29}$ ]] && fail "invalid slug '$SLUG' (regex: ^[a-z][a-z0-9_]{1,29}\$)"
|
||||
[[ -z "$NAME" ]] && fail "name required"
|
||||
[[ -z "$ACCOUNT_ID" ]] && fail "account_id required"
|
||||
[[ -z "$MARCA" ]] && fail "marca required"
|
||||
[[ -z "$UNIT_NAME" ]] && fail "unit_name required"
|
||||
[[ -z "$SOUL_MD" ]] && fail "soul_md content required"
|
||||
[[ -z "$SKILL_NAME" ]] && fail "skill_name required"
|
||||
[[ ! "$SKILL_NAME" =~ ^[a-z][a-z0-9_-]{1,40}$ ]] && fail "invalid skill_name '$SKILL_NAME'"
|
||||
[[ -z "$SKILL_MD" ]] && fail "skill_md content required"
|
||||
|
||||
# Categories validation: structure + amount sanity
|
||||
CATEGORIES_COUNT=$(echo "$SPEC" | jq '.categories | length')
|
||||
[[ "$CATEGORIES_COUNT" -lt 1 ]] && fail "at least 1 category required"
|
||||
|
||||
INVALID_AMOUNTS=$(echo "$SPEC" | jq '
|
||||
[.categories[] |
|
||||
.amounts[] |
|
||||
select(.amount <= 0 or .amount > 5000 or
|
||||
(.period | IN("2h","3h","4h","5h","pernoite_promo","pernoite_integral","diaria") | not) or
|
||||
(.day_bucket != null and (.day_bucket | IN("mon_wed","thu_sun") | not)))
|
||||
] | length
|
||||
')
|
||||
[[ "$INVALID_AMOUNTS" -gt 0 ]] && fail "$INVALID_AMOUNTS amounts inválidos (preço fora 0..5000, período/bucket inválido)"
|
||||
|
||||
# Profile already exists?
|
||||
if [[ -d "$PROFILES_DIR/$SLUG" ]]; then
|
||||
log "profile $SLUG já existe, será re-validado mas não recriado (idempotente)"
|
||||
fi
|
||||
|
||||
# === ALLOCATE PORT ===
|
||||
allocate_port() {
|
||||
for ((p=PORT_RANGE_START; p<=PORT_RANGE_END; p++)); do
|
||||
if ! ss -tnlH "( sport = :$p )" | grep -q .; then
|
||||
echo "$p"; return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reuse port if profile exists, else allocate fresh
|
||||
if [[ -f "$PROFILES_DIR/$SLUG/config.yaml" ]]; then
|
||||
PORT=$(awk '/^ port:/ {print $2}' "$PROFILES_DIR/$SLUG/config.yaml" | head -1)
|
||||
log "reusing existing port $PORT for $SLUG"
|
||||
else
|
||||
PORT=$(allocate_port) || fail "no free port in range $PORT_RANGE_START..$PORT_RANGE_END"
|
||||
log "allocated port $PORT for $SLUG"
|
||||
fi
|
||||
|
||||
# === GENERATE OR REUSE HMAC SECRET ===
|
||||
if [[ -f "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" ]]; then
|
||||
SECRET=$(jq -r ".\"captain-inbox-${SLUG}\".secret // empty" "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" 2>/dev/null)
|
||||
[[ -z "$SECRET" ]] && SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
|
||||
else
|
||||
SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
|
||||
fi
|
||||
|
||||
# === DRY RUN STOPS HERE ===
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "$SPEC" | jq --arg port "$PORT" --arg secret "${SECRET:0:8}..." \
|
||||
'{ok: true, dry_run: true, slug: .slug, name: .name, port: $port, secret_preview: $secret, categories: (.categories | length)}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# === DB OPERATIONS via docker exec ===
|
||||
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
|
||||
[[ -z "$CID" ]] && fail "iachat_iachat_app container not running"
|
||||
|
||||
# Persist spec to a tmp file inside container so the runner reads it back
|
||||
TMP_SPEC="/tmp/hermes_spec_${SLUG}_$$.json"
|
||||
echo "$SPEC" > "/tmp/hermes_spec_${SLUG}_$$.json"
|
||||
docker cp "$TMP_SPEC" "$CID:$TMP_SPEC"
|
||||
|
||||
DB_RESULT=$(docker exec "$CID" bundle exec rails runner "
|
||||
spec = JSON.parse(File.read('$TMP_SPEC'))
|
||||
account_id = spec['account_id']
|
||||
|
||||
brand = Captain::Brand.find_by(account_id: account_id, name: spec['marca'])
|
||||
raise \"brand not found: #{spec['marca']}\" if brand.nil?
|
||||
|
||||
unit = if spec['captain_unit_id']
|
||||
Captain::Unit.find(spec['captain_unit_id'])
|
||||
else
|
||||
Captain::Unit.find_or_create_by!(account_id: account_id, captain_brand_id: brand.id, name: spec['unit_name']) do |u|
|
||||
u.status = 'active'
|
||||
u.extra_person_fee = (spec['extra_person_fee'] || 0).to_f
|
||||
u.currency = 'BRL'
|
||||
end
|
||||
end
|
||||
|
||||
spec['categories'].each do |cat|
|
||||
pricing_cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: cat['key'])
|
||||
pricing_cat.aliases = cat['aliases'] || []
|
||||
pricing_cat.extra_person_starts_at = cat['extra_person_starts_at'] || 3
|
||||
pricing_cat.save!
|
||||
|
||||
cat['amounts'].each do |a|
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: pricing_cat.id, period: a['period'], day_bucket: a['day_bucket']
|
||||
)
|
||||
row.amount = a['amount']
|
||||
row.save!
|
||||
end
|
||||
end
|
||||
|
||||
# CHAVE = hermes_profile_name (slug). Nome é cosmético e PODE colidir com
|
||||
# captain_interno do mesmo nome — nesse caso auto-renomeamos com sufixo
|
||||
# ' · Hermes' pra evitar sobrescrever.
|
||||
asst = Captain::Assistant.find_or_initialize_by(account_id: account_id, hermes_profile_name: spec['slug'])
|
||||
|
||||
desired_name = spec['name'].to_s.strip
|
||||
if asst.new_record?
|
||||
collision = Captain::Assistant.where(account_id: account_id, name: desired_name).where.not(hermes_profile_name: spec['slug']).exists?
|
||||
desired_name = desired_name + ' · Hermes' if collision && !desired_name.include?('Hermes')
|
||||
end
|
||||
asst.name = desired_name
|
||||
asst.description ||= 'Atendente Hermes ' + desired_name
|
||||
asst.engine = 'hermes'
|
||||
asst.hermes_profile_name = spec['slug']
|
||||
asst.hermes_webhook_base_url = 'http://172.17.0.1:' + $PORT.to_s
|
||||
asst.hermes_subscription_secret = '$SECRET'
|
||||
asst.hermes_port = $PORT
|
||||
asst.parent_assistant_id = spec['parent_assistant_id']
|
||||
asst.captain_unit_id = unit.id
|
||||
|
||||
if spec['humanization']
|
||||
asst.config['response_delay'] = spec['humanization']
|
||||
end
|
||||
|
||||
asst.save!
|
||||
|
||||
puts 'OK ' + asst.id.to_s + ' ' + unit.id.to_s
|
||||
" 2>&1 | grep -v 'RubyLLM\|ip_lookup\|WARN' | tail -3)
|
||||
|
||||
ASSISTANT_ID=$(echo "$DB_RESULT" | grep '^OK' | awk '{print $2}')
|
||||
[[ -z "$ASSISTANT_ID" ]] && fail "DB step failed: $DB_RESULT"
|
||||
log "DB step OK: assistant_id=$ASSISTANT_ID"
|
||||
|
||||
# === FILESYSTEM: profile directory ===
|
||||
mkdir -p "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/references"
|
||||
|
||||
# Copy template files (config base, plugins, auth, generic skills)
|
||||
if [[ -f "$TEMPLATE_PROFILE/config.yaml" ]]; then
|
||||
cp "$TEMPLATE_PROFILE/config.yaml" "$PROFILES_DIR/$SLUG/config.yaml"
|
||||
cp -r "$TEMPLATE_PROFILE/plugins" "$PROFILES_DIR/$SLUG/" 2>/dev/null || true
|
||||
cp "$TEMPLATE_PROFILE/.env" "$PROFILES_DIR/$SLUG/.env" 2>/dev/null || true
|
||||
cp "$TEMPLATE_PROFILE/auth.json" "$PROFILES_DIR/$SLUG/auth.json" 2>/dev/null || true
|
||||
for s in "$TEMPLATE_PROFILE/skills"/*/; do
|
||||
name=$(basename "$s")
|
||||
[[ "$name" == "dolce-amore-reservas" ]] && continue
|
||||
[[ "$name" == "$SKILL_NAME" ]] && continue
|
||||
cp -r "$s" "$PROFILES_DIR/$SLUG/skills/" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Patch config.yaml: port + X-Captain-Assistant-Id + DESLIGA memória
|
||||
# (Hermes-level memory_enabled e user_profile_enabled vazam contexto entre
|
||||
# agentes que compartilham OAuth Codex; manter desligado pra evitar
|
||||
# contaminação cross-unit).
|
||||
#
|
||||
# X-Captain-Assistant-Id usa o id PRÓPRIO do Hermes assistant (não do
|
||||
# parent). Caso contrário tools como faq_lookup buscam dados do parent
|
||||
# (Captain interno, com FAQs antigos) — vazou senha errada do Wi-Fi em
|
||||
# 2026-05-02 porque parent.id=1 tinha "presencial" enquanto own.id=10
|
||||
# tinha a senha real "Prime2025".
|
||||
sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml"
|
||||
sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml"
|
||||
# memory_enabled / user_profile_enabled ficam LIGADOS (default da Valentina
|
||||
# template). Antes desligávamos achando que evitaria contaminação cross-unit
|
||||
# — mas a contaminação real vinha do X-Captain-Assistant-Id apontando pro
|
||||
# parent (já corrigido). Memória off mata UX (cliente repete nome/CPF a
|
||||
# cada turn), e cada Hermes tem session isolada por chat_id, então memória
|
||||
# de uma conv não vaza pra outra naturalmente.
|
||||
|
||||
# SOUL.md: clona a da Valentina (template canônico) e substitui identidade.
|
||||
# Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx],
|
||||
# tools, regras de fluxo — vem direto da Valentina e fica em sync conforme
|
||||
# ela evolui.
|
||||
BRAND_NAME=$(echo "$SPEC" | jq -r '.marca')
|
||||
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name')
|
||||
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name')
|
||||
CATEGORIAS_LISTA=$(echo "$SPEC" | jq -r '.categories | map(.key) | join(", ")')
|
||||
|
||||
cp "$TEMPLATE_PROFILE/SOUL.md" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
# Identity replacements (atenção: ordem importa pra strings que se sobrepõem).
|
||||
sed -i "s|Dolce Amore Motel|$BRAND_NAME — $UNIT_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
sed -i "s|Valentina|$NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
sed -i "s|dolce-amore-reservas|$SKILL_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
|
||||
# Substitui exemplos hardcoded de categorias Dolce Amore (Mini Chalé 45 etc) pelas
|
||||
# 3 primeiras categorias da unidade nova. Sem isso, SOUL.md vaza Dolce Amore-isms
|
||||
# em descrições de tools mesmo após sed de identidade.
|
||||
EX_CATS_LIST=$(echo "$SPEC" | jq -r '[.categories[0:3] | .[] | "\"" + .key + "\""] | join(", ")')
|
||||
FIRST_CAT=$(echo "$SPEC" | jq -r '.categories[0].key // "categoria"')
|
||||
sed -i "s|\"Master\", \"Luxo\", \"Mini Chalé 45\"|$EX_CATS_LIST|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
sed -i "s|Prefere Suíte Master|Prefere $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
sed -i "s|prefiro suíte master|prefiro $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
|
||||
# Localização: a Valentina template é Dolce Amore (Ponta Negra, Natal/RN).
|
||||
# Sem este sed, novos agentes vazam essa cidade — vimos isso na Juliana
|
||||
# Qnn01 que ficou "em Ponta Negra, Natal/RN" mesmo sendo de Brasília.
|
||||
if [[ -n "$CITY" ]]; then
|
||||
sed -i "s|em Ponta Negra, Natal/RN|em $CITY|g" "$PROFILES_DIR/$SLUG/SOUL.md"
|
||||
fi
|
||||
|
||||
# Skill: usa o markdown gerado pelo expand_spec (tabela do banco + regras).
|
||||
echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md"
|
||||
|
||||
# Anti-leak no SOUL.md (proteção contra contaminação cross-unit via Codex).
|
||||
cat >> "$PROFILES_DIR/$SLUG/SOUL.md" <<GUARD
|
||||
|
||||
## 🚨 REGRA CRÍTICA — IGNORE OUTRAS UNIDADES
|
||||
|
||||
Você atende APENAS esta unidade ($BRAND_NAME — $UNIT_NAME). Suas categorias são EXCLUSIVAMENTE: $CATEGORIAS_LISTA.
|
||||
|
||||
Se algum sinal externo (memória de conversas anteriores, contexto compartilhado, exemplos em prompt de tool) sugerir categorias FORA dessa lista — IGNORE COMPLETAMENTE.
|
||||
|
||||
Se cliente perguntar sobre algo de outra unidade, responda: "Aqui temos $CATEGORIAS_LISTA. Quer que eu te passe o valor de alguma?".
|
||||
GUARD
|
||||
|
||||
# Write webhook_subscriptions.json
|
||||
cat > "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" <<JSON
|
||||
{
|
||||
"captain-inbox-${SLUG}": {
|
||||
"description": "Subscription Hermes — $NAME",
|
||||
"events": [],
|
||||
"secret": "$SECRET",
|
||||
"prompt": "[ctx: cid={conversation_internal_id} aid={account_id} contact={contact_id} name=\"{contact_first_name}\" full_name=\"{contact_name}\" cpf_ok={contact_cpf_present} email_ok={contact_email_present} reservas={contact_total_reservas}]\nCliente acabou de dizer no WhatsApp: \"{message}\"\n\nResponda como $NAME, brevemente, em português brasileiro. Use a skill $SKILL_NAME pra preços, regras e fluxo. Nunca se apresente. Nunca diga que é IA.",
|
||||
"skills": [],
|
||||
"deliver": "http_callback",
|
||||
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"deliver_extra": {
|
||||
"chat_id": "https://iachat.hoteis1001noites.com.br/webhooks/captain/hermes_callback?slug=${SLUG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
log "filesystem OK"
|
||||
|
||||
# === SYSTEMD ===
|
||||
cat > "$SYSTEMD_DIR/hermes@.service" <<UNIT
|
||||
[Unit]
|
||||
Description=Hermes Agent gateway for profile %i
|
||||
After=network.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=HERMES_HOME=$PROFILES_DIR/%i
|
||||
ExecStart=/usr/local/bin/hermes gateway run --replace
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:$PROFILES_DIR/%i/logs/gateway.log
|
||||
StandardError=append:$PROFILES_DIR/%i/logs/gateway.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
mkdir -p "$PROFILES_DIR/$SLUG/logs"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "hermes@$SLUG.service" >/dev/null 2>&1
|
||||
systemctl restart "hermes@$SLUG.service"
|
||||
sleep 2
|
||||
|
||||
if ! ss -tnlH "( sport = :$PORT )" | grep -q .; then
|
||||
log "WARNING: daemon for $SLUG not listening on $PORT after 2s — check /root/.hermes/profiles/$SLUG/logs/gateway.log"
|
||||
else
|
||||
log "daemon listening on port $PORT"
|
||||
fi
|
||||
|
||||
# === GIT BACKUP ===
|
||||
if [[ -d "$GIT_BACKUP_REPO/.git" ]]; then
|
||||
cd "$GIT_BACKUP_REPO"
|
||||
rsync -a --delete --exclude='logs/' --exclude='cache/' --exclude='sessions/' \
|
||||
--exclude='state.db*' --exclude='memories/' --exclude='sandboxes/' \
|
||||
--exclude='.skills_prompt_snapshot.json' --exclude='auth.json' \
|
||||
--exclude='.env' --exclude='webhook_subscriptions.json' \
|
||||
"$PROFILES_DIR/$SLUG/" "./profiles/$SLUG/"
|
||||
git add "profiles/$SLUG"
|
||||
git commit -m "provision: $SLUG ($NAME)" >/dev/null 2>&1 || true
|
||||
git push origin main >/dev/null 2>&1 || log "git push failed (silent — backup local OK)"
|
||||
fi
|
||||
|
||||
# === OUTPUT ===
|
||||
jq -n --arg slug "$SLUG" --arg name "$NAME" --argjson aid "$ASSISTANT_ID" --argjson port "$PORT" \
|
||||
'{ok: true, slug: $slug, name: $name, assistant_id: $aid, port: $port, listening: true}'
|
||||
@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# hermes-validate <slug> — auditoria completa de um agente Hermes
|
||||
#
|
||||
# Uso:
|
||||
# hermes-validate juliana_qnn1
|
||||
# hermes-validate valentina --json # output JSON-only pra parsing
|
||||
#
|
||||
# Roda 42 checks em DB / filesystem / daemon / routing / tripé humanização /
|
||||
# tools MCP / pricing. Reporta PASS/FAIL/WARN por item + resumo final.
|
||||
#
|
||||
# Exit code 0 = sem FAIL. 1 = pelo menos um FAIL.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SLUG="${1:-}"
|
||||
JSON_MODE=0
|
||||
[[ "${2:-}" == "--json" ]] && JSON_MODE=1
|
||||
|
||||
PROFILES_DIR="/root/.hermes/profiles"
|
||||
DOCKER_APP_FILTER="iachat_iachat_app"
|
||||
|
||||
[[ -z "$SLUG" ]] && { echo "uso: hermes-validate <slug> [--json]"; exit 2; }
|
||||
|
||||
PASS=0; FAIL=0; WARN=0
|
||||
RESULTS_JSON='[]'
|
||||
|
||||
green() { printf "\033[32m✓\033[0m %s\n" "$1"; }
|
||||
red() { printf "\033[31m✗\033[0m %s\n" "$1"; }
|
||||
yellow(){ printf "\033[33m⚠\033[0m %s\n" "$1"; }
|
||||
|
||||
check() {
|
||||
local label="$1" status="$2" detail="${3:-}"
|
||||
case "$status" in
|
||||
PASS) PASS=$((PASS+1)); [[ $JSON_MODE -eq 0 ]] && green "$label${detail:+ — $detail}" ;;
|
||||
FAIL) FAIL=$((FAIL+1)); [[ $JSON_MODE -eq 0 ]] && red "$label${detail:+ — $detail}" ;;
|
||||
WARN) WARN=$((WARN+1)); [[ $JSON_MODE -eq 0 ]] && yellow "$label${detail:+ — $detail}" ;;
|
||||
esac
|
||||
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg l "$label" --arg s "$status" --arg d "$detail" '. + [{label: $l, status: $s, detail: $d}]')
|
||||
}
|
||||
|
||||
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
|
||||
[[ -z "$CID" ]] && { echo "iachat_iachat_app container não rodando"; exit 2; }
|
||||
|
||||
PROFILE_DIR="$PROFILES_DIR/$SLUG"
|
||||
|
||||
# ============================================================
|
||||
# Coleta dados do DB num único rails runner pra evitar 30 docker execs
|
||||
# ============================================================
|
||||
DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
|
||||
asst = Captain::Assistant.find_by(hermes_profile_name: '$SLUG', engine: 'hermes')
|
||||
if asst.nil?
|
||||
puts({error: 'no_assistant'}.to_json)
|
||||
exit
|
||||
end
|
||||
unit = asst.captain_unit
|
||||
brand = unit&.brand
|
||||
ci = CaptainInbox.where(captain_assistant_id: asst.id).first
|
||||
inbox = ci&.inbox
|
||||
hum = asst.config['response_delay']
|
||||
cats = unit&.pricing_categories&.includes(:amounts)&.to_a || []
|
||||
galleria = unit&.gallery_items&.count || 0
|
||||
ci_unit_id = ci&.captain_unit_id
|
||||
inter_ok = unit && unit.respond_to?(:inter_credentials_present?) ? unit.inter_credentials_present? : false
|
||||
pricing_dry_run = nil
|
||||
if unit && cats.any?
|
||||
first_cat_key = cats.first.key
|
||||
res = Captain::Mcp::PricingTables.calculate(
|
||||
unit_id: unit.id, suite_category: first_cat_key,
|
||||
period: 'pernoite_promo', total_guests: 2
|
||||
)
|
||||
pricing_dry_run = res[:error] ? \"ERR: #{res[:error]}\" : \"OK R$ #{res[:amount]} (#{first_cat_key}/pernoite)\"
|
||||
end
|
||||
out = {
|
||||
assistant_id: asst.id,
|
||||
name: asst.name,
|
||||
engine: asst.engine,
|
||||
profile_name: asst.hermes_profile_name,
|
||||
port: asst.hermes_port,
|
||||
secret_present: !asst.hermes_subscription_secret.nil?,
|
||||
base_url: asst.hermes_webhook_base_url,
|
||||
parent_id: asst.parent_assistant_id,
|
||||
unit_id: unit&.id,
|
||||
unit_name: unit&.name,
|
||||
brand_name: brand&.name,
|
||||
cats_count: cats.size,
|
||||
cats_keys: cats.map(&:key),
|
||||
amounts_total: cats.flat_map { |c| c.amounts.to_a }.size,
|
||||
inbox_id: inbox&.id,
|
||||
inbox_name: inbox&.name,
|
||||
inbox_typing_delay: inbox&.typing_delay,
|
||||
response_delay: hum,
|
||||
gallery_count: galleria,
|
||||
enabled_for: inbox ? Captain::Hermes.enabled_for?(inbox) : nil,
|
||||
webhook_url: inbox ? Captain::Hermes.webhook_url_for(inbox) : nil,
|
||||
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil,
|
||||
ci_unit_id: ci_unit_id,
|
||||
inter_ok: inter_ok,
|
||||
pricing_dry_run: pricing_dry_run
|
||||
}
|
||||
puts out.to_json
|
||||
" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
|
||||
|
||||
if [[ -z "$DB_DUMP" ]] || ! echo "$DB_DUMP" | jq -e . >/dev/null 2>&1; then
|
||||
echo "DB query falhou. Output: $DB_DUMP"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if echo "$DB_DUMP" | jq -e '.error' >/dev/null; then
|
||||
red "Captain::Assistant com hermes_profile_name='$SLUG' não existe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSISTANT_ID=$(echo "$DB_DUMP" | jq -r '.assistant_id')
|
||||
PORT=$(echo "$DB_DUMP" | jq -r '.port')
|
||||
INBOX_ID=$(echo "$DB_DUMP" | jq -r '.inbox_id // ""')
|
||||
PARENT_ID=$(echo "$DB_DUMP" | jq -r '.parent_id // ""')
|
||||
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "=== Validando $SLUG (assistant_id=$ASSISTANT_ID, port=$PORT) ==="
|
||||
|
||||
# ============================================================
|
||||
# A. DB Captain
|
||||
# ============================================================
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "--- A. DB ---"
|
||||
check "engine='hermes'" "$([[ $(echo "$DB_DUMP" | jq -r '.engine') == 'hermes' ]] && echo PASS || echo FAIL)"
|
||||
check "hermes_profile_name setado" "$([[ $(echo "$DB_DUMP" | jq -r '.profile_name') != 'null' && $(echo "$DB_DUMP" | jq -r '.profile_name') != '' ]] && echo PASS || echo FAIL)"
|
||||
check "hermes_port setado" "$([[ "$PORT" != 'null' && "$PORT" != '' ]] && echo PASS || echo FAIL)" "port=$PORT"
|
||||
check "hermes_subscription_secret setado" "$([[ $(echo "$DB_DUMP" | jq -r '.secret_present') == 'true' ]] && echo PASS || echo FAIL)"
|
||||
check "hermes_webhook_base_url" "$([[ $(echo "$DB_DUMP" | jq -r '.base_url') =~ ^http ]] && echo PASS || echo FAIL)"
|
||||
check "parent_assistant_id setado" "$([[ "$PARENT_ID" != 'null' && "$PARENT_ID" != '' ]] && echo PASS || echo WARN)" "parent=$PARENT_ID"
|
||||
check "captain_unit_id setado" "$([[ $(echo "$DB_DUMP" | jq -r '.unit_id') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.unit_name')"
|
||||
ASSIST_UNIT=$(echo "$DB_DUMP" | jq -r '.unit_id // ""')
|
||||
CI_UNIT=$(echo "$DB_DUMP" | jq -r '.ci_unit_id // ""')
|
||||
check "CaptainInbox.unit == Assistant.unit (sem divergência)" "$([[ -n "$ASSIST_UNIT" && "$ASSIST_UNIT" == "$CI_UNIT" ]] && echo PASS || echo FAIL)" "asst=$ASSIST_UNIT ci=$CI_UNIT"
|
||||
check "Brand resolvida" "$([[ $(echo "$DB_DUMP" | jq -r '.brand_name') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.brand_name')"
|
||||
check "Pricing categorias > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.cats_count') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.cats_count') cats: $(echo "$DB_DUMP" | jq -r '.cats_keys | join(",")')"
|
||||
check "Pricing amounts > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.amounts_total') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.amounts_total') amounts"
|
||||
PRICING_DRY=$(echo "$DB_DUMP" | jq -r '.pricing_dry_run // ""')
|
||||
check "Pricing dry-run (calcula sem erro)" "$([[ "$PRICING_DRY" == OK* ]] && echo PASS || echo FAIL)" "$PRICING_DRY"
|
||||
INTER_OK=$(echo "$DB_DUMP" | jq -r '.inter_ok // false')
|
||||
check "Credenciais Inter completas (cert+key+client_id)" "$([[ "$INTER_OK" == 'true' ]] && echo PASS || echo WARN)" "Sem isso generate_pix cai no fallback de link"
|
||||
check "CaptainInbox mapeada" "$([[ "$INBOX_ID" != 'null' && "$INBOX_ID" != '' ]] && echo PASS || echo WARN)" "inbox=$INBOX_ID"
|
||||
check "Inbox.typing_delay > 0 (debounce)" "$([[ $(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0')s"
|
||||
check "config.response_delay (typing simulation)" "$([[ $(echo "$DB_DUMP" | jq -r '.response_delay.mode // ""') == 'typing_simulation' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.response_delay.mode // "none"')"
|
||||
check "GalleryItem para fotos" "$([[ $(echo "$DB_DUMP" | jq -r '.gallery_count') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.gallery_count') items (send_suite_images precisa)"
|
||||
|
||||
# ============================================================
|
||||
# B. Filesystem
|
||||
# ============================================================
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "--- B. Filesystem ---"
|
||||
check "Pasta do profile existe" "$([[ -d "$PROFILE_DIR" ]] && echo PASS || echo FAIL)" "$PROFILE_DIR"
|
||||
|
||||
if [[ -d "$PROFILE_DIR" ]]; then
|
||||
SOUL_LINES=$(wc -l < "$PROFILE_DIR/SOUL.md" 2>/dev/null || echo 0)
|
||||
check "SOUL.md ≥ 300 linhas" "$([[ $SOUL_LINES -ge 300 ]] && echo PASS || echo WARN)" "$SOUL_LINES linhas"
|
||||
RESID=$(grep -c 'Dolce Amore Motel\|Mini Chalé 45\|Suíte Ouro\|Chalé 2 Suítes' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
|
||||
RESID=${RESID:-0}
|
||||
check "SOUL.md sem resíduo Dolce Amore" "$([[ $RESID -eq 0 ]] && echo PASS || echo FAIL)" "$RESID ocorrências"
|
||||
IVR=$(grep -c 'RESPONDA ANTES DE PERGUNTAR\|Info vs Reserva' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
|
||||
check "SOUL.md tem Info-vs-Reserva" "$([[ ${IVR:-0} -gt 0 ]] && echo PASS || echo WARN)"
|
||||
ALG=$(grep -c 'IGNORE OUTRAS UNIDADES' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
|
||||
check "SOUL.md tem anti-leak guard" "$([[ ${ALG:-0} -gt 0 ]] && echo PASS || echo WARN)"
|
||||
check "skills/<skill_name>/SKILL.md existe" "$([[ -n "$(find "$PROFILE_DIR/skills" -mindepth 2 -name 'SKILL.md' | grep -v dogfood | head -1)" ]] && echo PASS || echo FAIL)"
|
||||
check "dolce-amore-reservas NÃO está em skills/" "$([[ ! -d "$PROFILE_DIR/skills/dolce-amore-reservas" ]] && echo PASS || echo FAIL)"
|
||||
SUB_KEY=$(jq -r 'keys[0] // ""' "$PROFILE_DIR/webhook_subscriptions.json" 2>/dev/null)
|
||||
check "webhook_subscriptions.json com chave correta" "$([[ "$SUB_KEY" == "captain-inbox-$SLUG" ]] && echo PASS || echo FAIL)" "$SUB_KEY"
|
||||
PORT_OK=$(grep -c "port: $PORT" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
|
||||
check "config.yaml port=$PORT" "$([[ ${PORT_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
|
||||
HDR_OK=$(grep -c "X-Captain-Assistant-Id: '${PARENT_ID:-$ASSISTANT_ID}'" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
|
||||
check "config.yaml X-Captain-Assistant-Id correto" "$([[ ${HDR_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
|
||||
MEM_OFF=$(grep -c 'memory_enabled: false' "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
|
||||
check "config.yaml memory_enabled: false" "$([[ ${MEM_OFF:-0} -gt 0 ]] && echo PASS || echo WARN)"
|
||||
check "auth.json existe" "$([[ -f "$PROFILE_DIR/auth.json" ]] && echo PASS || echo FAIL)"
|
||||
check "plugins/captain-http-callback presente" "$([[ -d "$PROFILE_DIR/plugins/captain-http-callback" ]] && echo PASS || echo FAIL)"
|
||||
check "plugins/captain-webhook presente" "$([[ -d "$PROFILE_DIR/plugins/captain-webhook" ]] && echo PASS || echo FAIL)"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# C. Daemon / systemd
|
||||
# ============================================================
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "--- C. Daemon ---"
|
||||
check "systemd unit hermes@$SLUG ativa" "$([[ $(systemctl is-active "hermes@$SLUG.service" 2>/dev/null) == 'active' ]] && echo PASS || echo FAIL)"
|
||||
check "systemd unit enabled" "$([[ $(systemctl is-enabled "hermes@$SLUG.service" 2>/dev/null) == 'enabled' ]] && echo PASS || echo WARN)"
|
||||
check "Daemon escutando na porta $PORT" "$(ss -tnlH "( sport = :$PORT )" 2>/dev/null | grep -q . && echo PASS || echo FAIL)"
|
||||
ERR_COUNT=$(journalctl -u "hermes@$SLUG.service" --since '10 minutes ago' --no-pager 2>/dev/null | grep -ciE 'error|fatal|critical' || true)
|
||||
ERR_COUNT=${ERR_COUNT:-0}
|
||||
check "Logs sem erro recente" "$([[ $ERR_COUNT -eq 0 ]] && echo PASS || echo WARN)" "$ERR_COUNT erros nos últimos 10min"
|
||||
|
||||
# ============================================================
|
||||
# D. Roteamento Captain ↔ Hermes
|
||||
# ============================================================
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "--- D. Roteamento ---"
|
||||
check "Captain::Hermes.enabled_for? = true" "$([[ $(echo "$DB_DUMP" | jq -r '.enabled_for') == 'true' ]] && echo PASS || echo FAIL)"
|
||||
EXPECTED_URL="$(echo "$DB_DUMP" | jq -r '.base_url')/webhooks/captain-inbox-$SLUG"
|
||||
ACTUAL_URL=$(echo "$DB_DUMP" | jq -r '.webhook_url // ""')
|
||||
check "webhook_url aponta pra $SLUG" "$([[ "$ACTUAL_URL" == "$EXPECTED_URL" ]] && echo PASS || echo FAIL)" "$ACTUAL_URL"
|
||||
check "subscription_signing_secret retorna do DB" "$([[ -n "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" && "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" != 'null' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.secret_via // "nil"')..."
|
||||
|
||||
# ============================================================
|
||||
# E. MCP tools list (daemon registra todas)
|
||||
# ============================================================
|
||||
[[ $JSON_MODE -eq 0 ]] && echo "--- E. MCP tools (no Captain) ---"
|
||||
EXPECTED_TOOLS=(generate_pix faq_lookup add_label send_suite_images react_to_message update_contact get_contact_history check_pix_payment reschedule_reservation)
|
||||
TOOLS_REGISTRY=$(docker exec "$CID" bundle exec rails runner "puts Captain::Mcp::ToolRegistry::TOOLS.map(&:name).join(',')" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
|
||||
for tool in "${EXPECTED_TOOLS[@]}"; do
|
||||
check "MCP tool '$tool' registrado" "$([[ "$TOOLS_REGISTRY" == *"$tool"* ]] && echo PASS || echo FAIL)"
|
||||
done
|
||||
|
||||
# ============================================================
|
||||
# Resumo
|
||||
# ============================================================
|
||||
TOTAL=$((PASS+FAIL+WARN))
|
||||
if [[ $JSON_MODE -eq 1 ]]; then
|
||||
jq -n --arg slug "$SLUG" --argjson pass $PASS --argjson fail $FAIL --argjson warn $WARN --argjson total $TOTAL --argjson results "$RESULTS_JSON" \
|
||||
'{slug: $slug, total: $total, pass: $pass, fail: $fail, warn: $warn, ok: ($fail == 0), results: $results}'
|
||||
else
|
||||
echo
|
||||
echo "=== Resumo de $SLUG ==="
|
||||
echo "Total: $TOTAL · ${PASS} PASS · ${FAIL} FAIL · ${WARN} WARN"
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
echo "✅ Sem falhas críticas. Pode soltar."
|
||||
else
|
||||
echo "❌ $FAIL falha(s) — corrigir antes de soltar pro cliente."
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $FAIL -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
@ -184,7 +184,7 @@
|
||||
# MARK: Captain Config
|
||||
- name: CAPTAIN_LLM_PROVIDER
|
||||
display_title: 'Captain LLM Provider'
|
||||
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional), openai_codex_oauth (assinatura ChatGPT Plus via proxy interno) ou openai_hermes_gateway (Hermes Agent rodando como gateway HTTP local — ele faz o roteamento multi-modelo via OAuth).'
|
||||
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional) ou openai_codex_oauth (assinatura ChatGPT Plus via proxy interno).'
|
||||
value: 'openai_api'
|
||||
locked: false
|
||||
- name: CAPTAIN_CODEX_PROXY_URL
|
||||
@ -192,21 +192,6 @@
|
||||
description: 'URL base do proxy Codex interno quando CAPTAIN_LLM_PROVIDER=openai_codex_oauth. Default: http://localhost:3000/codex'
|
||||
value: 'http://localhost:3000/codex'
|
||||
locked: false
|
||||
- name: CAPTAIN_HERMES_GATEWAY_URL
|
||||
display_title: 'Captain Hermes Gateway URL'
|
||||
description: 'URL base do Hermes Gateway quando CAPTAIN_LLM_PROVIDER=openai_hermes_gateway. Default: http://host.docker.internal:9877 (Hermes rodando no host, container alcança via host.docker.internal).'
|
||||
value: 'http://host.docker.internal:9877'
|
||||
locked: false
|
||||
- name: CAPTAIN_HERMES_GATEWAY_MODEL
|
||||
display_title: 'Captain Hermes Gateway Model'
|
||||
description: 'Modelo a passar pro Hermes Gateway no formato <provider>/<model>. Default: anthropic/claude-opus-4-5. O Hermes faz o roteamento real e pode usar Codex/Anthropic/Gemini conforme config local em ~/.hermes/config.yaml.'
|
||||
value: 'anthropic/claude-opus-4-5'
|
||||
locked: false
|
||||
- name: CAPTAIN_HERMES_GATEWAY_API_KEY
|
||||
display_title: 'Captain Hermes Gateway API Key (optional)'
|
||||
description: 'API key opcional pro Hermes Gateway. Geralmente vazio (gateway local não exige auth). Se setado, vai no Authorization header das requisições do Captain pro Hermes.'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CAPTAIN_OPEN_AI_API_KEY
|
||||
display_title: 'OpenAI API Key'
|
||||
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
|
||||
|
||||
@ -58,15 +58,6 @@ Rails.application.routes.draw do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
namespace :captain do
|
||||
resources :hermes_builder, only: [:index, :create] do
|
||||
collection do
|
||||
post :start
|
||||
delete :reset
|
||||
get :assistants
|
||||
get :validate
|
||||
post :repair
|
||||
end
|
||||
end
|
||||
resource :preferences, only: [:show, :update]
|
||||
resources :assistants do
|
||||
member do
|
||||
@ -284,6 +275,8 @@ Rails.application.routes.draw do
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
post :on_whatsapp, on: :member
|
||||
resources :notification_templates, only: [:index, :create, :update, :destroy],
|
||||
module: 'captain'
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
@ -524,9 +517,6 @@ Rails.application.routes.draw do
|
||||
get :inbox_label_matrix
|
||||
get :first_response_time_distribution
|
||||
get :outgoing_messages_count
|
||||
get :inbox_leads_summary
|
||||
get :conversion_funnel
|
||||
get :inbox_benchmarking
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
@ -646,9 +636,6 @@ Rails.application.routes.draw do
|
||||
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
|
||||
post 'webhooks/shopify', to: 'webhooks/shopify#events'
|
||||
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
|
||||
post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload'
|
||||
post 'webhooks/captain/builder_callback', to: 'webhooks/captain/hermes_builder_callback#process_payload'
|
||||
post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload'
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
|
||||
@ -19,8 +19,7 @@ class SeedJasmineAndDanielaPrompts < ActiveRecord::Migration[7.1]
|
||||
'jasmine_qnn01' => 'Jasmine( Qnn01)',
|
||||
'jasmine_primeal' => 'Jasmine(PrimeAL)',
|
||||
'jasmine_primevl' => 'Jasmine(PrimeVL)',
|
||||
'jasmine_express' => 'Jasmine (Express)',
|
||||
'jasmine_dolce_amore' => 'Jasmine(DolceAmore)'
|
||||
'jasmine_express' => 'Jasmine (Express)'
|
||||
}.freeze
|
||||
|
||||
SCENARIO_TITLE_MAP = {
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
class AddSupabaseMappingToCaptainUnits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_units, :supabase_unit_id, :uuid
|
||||
add_column :captain_units, :supabase_tenant_id, :bigint, default: 1
|
||||
add_column :captain_units, :supabase_marca_id, :uuid
|
||||
|
||||
add_index :captain_units, :supabase_unit_id, unique: true, where: 'supabase_unit_id IS NOT NULL'
|
||||
end
|
||||
end
|
||||
@ -1,8 +0,0 @@
|
||||
class AddEngineToCaptainAssistants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_assistants, :engine, :string, default: 'captain_interno', null: false
|
||||
add_column :captain_assistants, :hermes_profile_name, :string
|
||||
add_column :captain_assistants, :hermes_webhook_base_url, :string
|
||||
add_index :captain_assistants, :engine
|
||||
end
|
||||
end
|
||||
@ -1,29 +0,0 @@
|
||||
class CreateCaptainPricingTables < ActiveRecord::Migration[7.1]
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def change
|
||||
add_column :captain_units, :extra_person_fee, :decimal, precision: 10, scale: 2, default: 0.0, null: false
|
||||
add_column :captain_units, :currency, :string, default: 'BRL', null: false
|
||||
|
||||
create_table :captain_pricing_categories do |t|
|
||||
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
|
||||
t.string :key, null: false
|
||||
t.jsonb :aliases, null: false, default: []
|
||||
t.integer :extra_person_starts_at, null: false, default: 3
|
||||
t.timestamps
|
||||
end
|
||||
add_index :captain_pricing_categories, [:captain_unit_id, :key], unique: true
|
||||
|
||||
create_table :captain_pricing_amounts do |t|
|
||||
t.references :captain_pricing_category, null: false, foreign_key: { to_table: :captain_pricing_categories }
|
||||
t.string :period, null: false
|
||||
t.string :day_bucket
|
||||
t.decimal :amount, precision: 10, scale: 2, null: false
|
||||
t.timestamps
|
||||
end
|
||||
add_index :captain_pricing_amounts,
|
||||
[:captain_pricing_category_id, :period, :day_bucket],
|
||||
unique: true,
|
||||
name: 'idx_captain_pricing_amount_uniq'
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
@ -1,14 +0,0 @@
|
||||
class AddProvisioningColumnsToCaptainAssistants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_assistants, :hermes_subscription_secret, :string
|
||||
add_column :captain_assistants, :hermes_port, :integer
|
||||
add_column :captain_assistants, :parent_assistant_id, :bigint
|
||||
|
||||
add_index :captain_assistants, :parent_assistant_id
|
||||
add_index :captain_assistants,
|
||||
:hermes_port,
|
||||
unique: true,
|
||||
where: 'hermes_port IS NOT NULL',
|
||||
name: 'idx_captain_assistants_hermes_port_unique'
|
||||
end
|
||||
end
|
||||
@ -1,9 +0,0 @@
|
||||
class AddCaptainUnitIdToCaptainAssistants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :captain_assistants,
|
||||
:captain_unit,
|
||||
foreign_key: { to_table: :captain_units, on_delete: :nullify },
|
||||
null: true,
|
||||
index: true
|
||||
end
|
||||
end
|
||||
@ -1,19 +0,0 @@
|
||||
class AddManualPixToCaptainUnits < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :captain_units, :pix_mode, :string, default: 'inter_dynamic', null: false
|
||||
add_column :captain_units, :manual_pix_key, :string
|
||||
add_column :captain_units, :manual_pix_key_type, :string
|
||||
add_column :captain_units, :manual_pix_owner_name, :string
|
||||
add_column :captain_units, :manual_pix_bank_name, :string
|
||||
|
||||
add_index :captain_units, :pix_mode, algorithm: :concurrently
|
||||
|
||||
add_column :captain_pix_charges, :provider, :string, default: 'inter', null: false
|
||||
add_column :captain_pix_charges, :manual_proof_payload, :jsonb
|
||||
add_column :captain_pix_charges, :manual_review_reason, :string
|
||||
|
||||
add_index :captain_pix_charges, :provider, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
44
db/schema.rb
44
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_22_145733) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@ -336,18 +336,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
t.text "api_key"
|
||||
t.jsonb "handoff_webhook_config", default: {}
|
||||
t.text "orchestrator_prompt"
|
||||
t.string "engine", default: "captain_interno", null: false
|
||||
t.string "hermes_profile_name"
|
||||
t.string "hermes_webhook_base_url"
|
||||
t.string "hermes_subscription_secret"
|
||||
t.integer "hermes_port"
|
||||
t.bigint "parent_assistant_id"
|
||||
t.bigint "captain_unit_id"
|
||||
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
|
||||
t.index ["captain_unit_id"], name: "index_captain_assistants_on_captain_unit_id"
|
||||
t.index ["engine"], name: "index_captain_assistants_on_engine"
|
||||
t.index ["hermes_port"], name: "idx_captain_assistants_hermes_port_unique", unique: true, where: "(hermes_port IS NOT NULL)"
|
||||
t.index ["parent_assistant_id"], name: "index_captain_assistants_on_parent_assistant_id"
|
||||
end
|
||||
|
||||
create_table "captain_brands", force: :cascade do |t|
|
||||
@ -684,28 +673,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
t.index ["unit_id"], name: "index_captain_pix_charges_on_unit_id"
|
||||
end
|
||||
|
||||
create_table "captain_pricing_amounts", force: :cascade do |t|
|
||||
t.bigint "captain_pricing_category_id", null: false
|
||||
t.string "period", null: false
|
||||
t.string "day_bucket"
|
||||
t.decimal "amount", precision: 10, scale: 2, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["captain_pricing_category_id", "period", "day_bucket"], name: "idx_captain_pricing_amount_uniq", unique: true
|
||||
t.index ["captain_pricing_category_id"], name: "index_captain_pricing_amounts_on_captain_pricing_category_id"
|
||||
end
|
||||
|
||||
create_table "captain_pricing_categories", force: :cascade do |t|
|
||||
t.bigint "captain_unit_id", null: false
|
||||
t.string "key", null: false
|
||||
t.jsonb "aliases", default: [], null: false
|
||||
t.integer "extra_person_starts_at", default: 3, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["captain_unit_id", "key"], name: "index_captain_pricing_categories_on_captain_unit_id_and_key", unique: true
|
||||
t.index ["captain_unit_id"], name: "index_captain_pricing_categories_on_captain_unit_id"
|
||||
end
|
||||
|
||||
create_table "captain_pricing_inboxes", force: :cascade do |t|
|
||||
t.bigint "captain_pricing_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
@ -997,16 +964,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
t.boolean "proactive_pix_polling_enabled", default: false, null: false
|
||||
t.bigint "concierge_inbox_id"
|
||||
t.jsonb "concierge_config", default: {}, null: false
|
||||
t.uuid "supabase_unit_id"
|
||||
t.bigint "supabase_tenant_id", default: 1
|
||||
t.uuid "supabase_marca_id"
|
||||
t.decimal "extra_person_fee", precision: 10, scale: 2, default: "0.0", null: false
|
||||
t.string "currency", default: "BRL", null: false
|
||||
t.index ["account_id"], name: "index_captain_units_on_account_id"
|
||||
t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_id"
|
||||
t.index ["concierge_inbox_id"], name: "index_captain_units_on_concierge_inbox_id"
|
||||
t.index ["inbox_id"], name: "index_captain_units_on_inbox_id"
|
||||
t.index ["supabase_unit_id"], name: "index_captain_units_on_supabase_unit_id", unique: true, where: "(supabase_unit_id IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "categories", force: :cascade do |t|
|
||||
@ -2168,7 +2129,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "captain_assets", "accounts"
|
||||
add_foreign_key "captain_assets", "captain_suites"
|
||||
add_foreign_key "captain_assistants", "captain_units", on_delete: :nullify
|
||||
add_foreign_key "captain_brands", "accounts"
|
||||
add_foreign_key "captain_configurations", "accounts"
|
||||
add_foreign_key "captain_contact_memories", "accounts", on_delete: :cascade
|
||||
@ -2199,8 +2159,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
|
||||
add_foreign_key "captain_notification_templates", "inboxes"
|
||||
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
|
||||
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
|
||||
add_foreign_key "captain_pricing_amounts", "captain_pricing_categories"
|
||||
add_foreign_key "captain_pricing_categories", "captain_units"
|
||||
add_foreign_key "captain_pricings", "accounts"
|
||||
add_foreign_key "captain_pricings", "captain_brands"
|
||||
add_foreign_key "captain_prompt_audit_events", "captain_prompt_profiles", column: "prompt_profile_id"
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
# Backfill one-time das tabelas de preço pra Dolce Amore (unit 4) e
|
||||
# Express (unit 5) — copia o que estava hardcoded no PricingTables.rb
|
||||
# antes da migração pra DB.
|
||||
#
|
||||
# Idempotente: roda find_or_create_by em tudo. Pode rodar várias vezes sem
|
||||
# criar duplicata.
|
||||
#
|
||||
# Uso:
|
||||
# docker exec iachat_iachat_app bundle exec rails runner db/seed_pricing_tables.rb
|
||||
#
|
||||
# rubocop:disable all
|
||||
|
||||
# Garante extra_person_fee + currency configurados nas units
|
||||
dolce = Captain::Unit.find(4)
|
||||
dolce.update!(extra_person_fee: 45.0, currency: 'BRL') if dolce.extra_person_fee.to_f.zero?
|
||||
|
||||
express = Captain::Unit.find(5)
|
||||
express.update!(extra_person_fee: 0.0, currency: 'BRL')
|
||||
|
||||
DOLCE_AMORE_DATA = {
|
||||
'apartamento' => {
|
||||
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 }
|
||||
},
|
||||
'suite_master' => {
|
||||
aliases: ['master', 'suite master', 'suíte master', '2 andares'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
|
||||
},
|
||||
'suite_luxo' => {
|
||||
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
|
||||
},
|
||||
'suite_tematica' => {
|
||||
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
|
||||
},
|
||||
'mini_chale_45' => {
|
||||
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 }
|
||||
},
|
||||
'chale_2_suites' => {
|
||||
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites'],
|
||||
extra_person_starts_at: 4,
|
||||
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 }
|
||||
},
|
||||
'suite_ouro' => {
|
||||
aliases: ['ouro', 'suite ouro', 'suíte ouro'],
|
||||
extra_person_starts_at: 4,
|
||||
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 }
|
||||
},
|
||||
'chale_master_4_suites' => {
|
||||
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites'],
|
||||
extra_person_starts_at: 8,
|
||||
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
EXPRESS_DATA = {
|
||||
'standard' => {
|
||||
aliases: %w[standard comum básica basica apartamento\ standard],
|
||||
extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
|
||||
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 120.0 },
|
||||
'diaria' => 150.0
|
||||
}
|
||||
},
|
||||
'master' => {
|
||||
aliases: ['master', 'melhor', 'suite master', 'suíte master'],
|
||||
extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
|
||||
'3h' => { 'mon_wed' => 60.0, 'thu_sun' => 75.0 },
|
||||
'4h' => { 'mon_wed' => 70.0 },
|
||||
'5h' => { 'thu_sun' => 85.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 120.0, 'thu_sun' => 140.0 },
|
||||
'diaria' => 160.0
|
||||
}
|
||||
},
|
||||
'singles' => {
|
||||
aliases: %w[singles single sozinho],
|
||||
extra_person_starts_at: 99,
|
||||
prices: {
|
||||
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
|
||||
'diaria' => 130.0
|
||||
}
|
||||
},
|
||||
'familia' => {
|
||||
aliases: %w[familia família familiar],
|
||||
extra_person_starts_at: 99,
|
||||
prices: {
|
||||
'pernoite_promo' => 160.0,
|
||||
'diaria' => 190.0
|
||||
}
|
||||
},
|
||||
'singles_duplo' => {
|
||||
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo'],
|
||||
extra_person_starts_at: 99,
|
||||
prices: {
|
||||
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
|
||||
'diaria' => 250.0
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def upsert(unit, data)
|
||||
data.each do |key, attrs|
|
||||
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
|
||||
cat.aliases = attrs[:aliases]
|
||||
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
|
||||
cat.save!
|
||||
|
||||
attrs[:prices].each do |period, value|
|
||||
if value.is_a?(Hash)
|
||||
value.each do |bucket, amount|
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
|
||||
)
|
||||
row.amount = amount
|
||||
row.save!
|
||||
end
|
||||
else
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
|
||||
)
|
||||
row.amount = value
|
||||
row.save!
|
||||
end
|
||||
end
|
||||
|
||||
puts "✓ unit=#{unit.id} #{key} (#{attrs[:prices].size} periods)"
|
||||
end
|
||||
end
|
||||
|
||||
upsert(dolce, DOLCE_AMORE_DATA)
|
||||
upsert(express, EXPRESS_DATA)
|
||||
|
||||
puts "--- summary ---"
|
||||
puts "dolce categories: #{dolce.pricing_categories.count}"
|
||||
puts "dolce amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: dolce.id }).count}"
|
||||
puts "express categories: #{express.pricing_categories.count}"
|
||||
puts "express amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: express.id }).count}"
|
||||
# rubocop:enable all
|
||||
@ -1,120 +0,0 @@
|
||||
# Seed pricing for units 1 (Hotel Recanto), 2 (PrimeAL), 3 (Qnn01) from
|
||||
# scenario data. Units 4 (Dolce), 5 (Express), 6 (Prime Ceilândia) já têm.
|
||||
#
|
||||
# Idempotente. Roda quantas vezes quiser.
|
||||
# rubocop:disable all
|
||||
|
||||
NOITES_DATA = {
|
||||
'standard' => {
|
||||
aliases: %w[standard comum], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
|
||||
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 150.0 },
|
||||
'diaria' => 170.0
|
||||
}
|
||||
},
|
||||
'luxo' => {
|
||||
aliases: ['luxo', 'classica', 'clássica'], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => 60.0, '3h' => 75.0, '4h' => 85.0,
|
||||
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 160.0 },
|
||||
'diaria' => 190.0
|
||||
}
|
||||
},
|
||||
'hidromassagem' => {
|
||||
aliases: %w[hidro hidromassagem banheira spa jacuzzi], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => 110.0, '3h' => 120.0, '4h' => 150.0,
|
||||
'pernoite_promo' => 250.0, 'diaria' => 300.0
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
PRIME_DATA = {
|
||||
'stilo' => {
|
||||
aliases: %w[stilo estilo], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||
'2h' => { 'mon_wed' => 60.0, 'thu_sun' => 70.0 },
|
||||
'3h' => { 'mon_wed' => 70.0, 'thu_sun' => 80.0 },
|
||||
'4h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 150.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 150.0, 'thu_sun' => 170.0 },
|
||||
'diaria' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 }
|
||||
}
|
||||
},
|
||||
'alexa' => {
|
||||
aliases: %w[alexa], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
|
||||
'2h' => { 'mon_wed' => 65.0, 'thu_sun' => 75.0 },
|
||||
'3h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
|
||||
'4h' => { 'mon_wed' => 80.0, 'thu_sun' => 90.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 140.0, 'thu_sun' => 160.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 },
|
||||
'diaria' => { 'mon_wed' => 170.0, 'thu_sun' => 200.0 }
|
||||
}
|
||||
},
|
||||
'hidromassagem' => {
|
||||
aliases: %w[hidro hidromassagem banheira spa jacuzzi ofuro], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 130.0, 'thu_sun' => 140.0 },
|
||||
'2h' => { 'mon_wed' => 150.0, 'thu_sun' => 160.0 },
|
||||
'3h' => { 'mon_wed' => 170.0, 'thu_sun' => 180.0 },
|
||||
'4h' => { 'mon_wed' => 190.0, 'thu_sun' => 200.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 260.0, 'thu_sun' => 280.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 280.0, 'thu_sun' => 300.0 },
|
||||
'diaria' => { 'mon_wed' => 350.0, 'thu_sun' => 370.0 }
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def upsert(unit, data)
|
||||
data.each do |key, attrs|
|
||||
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
|
||||
cat.aliases = attrs[:aliases]
|
||||
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
|
||||
cat.save!
|
||||
attrs[:prices].each do |period, value|
|
||||
if value.is_a?(Hash)
|
||||
value.each do |bucket, amount|
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
|
||||
)
|
||||
row.amount = amount
|
||||
row.save!
|
||||
end
|
||||
else
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
|
||||
)
|
||||
row.amount = value
|
||||
row.save!
|
||||
end
|
||||
end
|
||||
puts "✓ unit=#{unit.id} #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
# 1001 Noites brand units = 1 (Hotel Recanto), 3 (Qnn01)
|
||||
[1, 3].each do |id|
|
||||
u = Captain::Unit.find(id)
|
||||
u.update!(extra_person_fee: 45.0, currency: 'BRL') if u.extra_person_fee.to_f.zero?
|
||||
upsert(u, NOITES_DATA)
|
||||
end
|
||||
|
||||
# Prime brand unit = 2 (PrimeAL)
|
||||
u = Captain::Unit.find(2)
|
||||
u.update!(extra_person_fee: 0.0, currency: 'BRL')
|
||||
upsert(u, PRIME_DATA)
|
||||
|
||||
puts "--- summary ---"
|
||||
[1, 2, 3].each do |id|
|
||||
u = Captain::Unit.find(id)
|
||||
cats = u.pricing_categories.count
|
||||
amounts = Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: u.id }).count
|
||||
puts "unit #{id} #{u.name}: cats=#{cats} amounts=#{amounts}"
|
||||
end
|
||||
# rubocop:enable all
|
||||
@ -52,11 +52,10 @@ Os nomes batem com `name`/`title` no banco:
|
||||
|
||||
| Slug do arquivo | Captain::Assistant#name |
|
||||
|---|---|
|
||||
| `jasmine_qnn01` | `Jasmine( Qnn01)` |
|
||||
| `jasmine_primeal` | `Jasmine(PrimeAL)` |
|
||||
| `jasmine_primevl` | `Jasmine(PrimeVL)` |
|
||||
| `jasmine_express` | `Jasmine (Express)` |
|
||||
| `jasmine_dolce_amore` | `Jasmine(DolceAmore)` |
|
||||
| `jasmine_qnn01` | `Jasmine( Qnn01)` |
|
||||
| `jasmine_primeal` | `Jasmine(PrimeAL)` |
|
||||
| `jasmine_primevl` | `Jasmine(PrimeVL)` |
|
||||
| `jasmine_express` | `Jasmine (Express)` |
|
||||
|
||||
| Slug do cenário | Captain::Scenario#title |
|
||||
|---|---|
|
||||
@ -89,4 +88,3 @@ preenchermos os arquivos lá.
|
||||
- [ ] Qnn01
|
||||
- [ ] PrimeVL
|
||||
- [ ] Express
|
||||
- [ ] Dolce Amore (criado 2026-04-27 — primeira unidade fora do 1001 Noites; marca distinta, motel-first em Natal/RN; não testado em staging ainda)
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
# System
|
||||
You are Captain, a multi-agent system. Transfer via `handoff_to_[agent_name]`. Never mention handoffs to the customer.
|
||||
|
||||
# Identidade
|
||||
Você é {{name}}, atendente via WhatsApp de um estabelecimento de hospedagem. Primeiro contato: identifica intenção e roteia ao cenário certo. Tom: natural, ágil, simpático, brasileiro — como atendente humano.
|
||||
|
||||
# 👤 REGRA CRÍTICA — CUMPRIMENTE PELO PRIMEIRO NOME
|
||||
|
||||
**ANTES de cada resposta, OBRIGATORIAMENTE leia `# Contact Information → Name:` abaixo no Current Context.** Aplique esta lógica SEM EXCEÇÃO:
|
||||
|
||||
1. **Extraia o primeiro nome** de `Name:`:
|
||||
- Se `Name` tem 2+ palavras compostas só por letras (ex: "Rodrigo Borba Machado", "Maria Silva", "Ana Clara Souza") → primeiro nome = primeira palavra (ex: "Rodrigo", "Maria", "Ana").
|
||||
- Se `Name` é emoji (ex: "😅‼️"), muito curto (< 3 letras), apenas números, "Unknown" ou vazio → NÃO há primeiro nome. Pule a personalização.
|
||||
|
||||
2. **Na PRIMEIRA resposta da conversa** (quando vai mandar a saudação):
|
||||
- Se há primeiro nome → comece EXATAMENTE com `Oi, <primeiro_nome>!` (ex: "Oi, Rodrigo!"). Depois continue a saudação normalmente.
|
||||
- Se não há → use `Oi!` genérico.
|
||||
|
||||
3. **Em mensagens seguintes** da mesma conversa: use o primeiro nome de vez em quando (1 a cada 2-3 mensagens), em momentos naturais, como faria um atendente humano brasileiro. NÃO repita em toda frase.
|
||||
|
||||
**EXEMPLOS OBRIGATÓRIOS:**
|
||||
|
||||
| `Name` no Contact Information | Primeira resposta DEVE começar com |
|
||||
|---|---|
|
||||
| `Rodrigo Borba Machado` | `Oi, Rodrigo!` |
|
||||
| `Maria Silva` | `Oi, Maria!` |
|
||||
| `😅‼️` ou vazio ou `Unknown` | `Oi!` (sem nome) |
|
||||
| `Rodrigo` (uma palavra só) | `Oi, Rodrigo!` |
|
||||
|
||||
Violar essa regra (cumprimentar sem nome quando `Name` é válido) é erro grave de atendimento. O cliente **já forneceu o nome em interação anterior** e espera que lembremos dele.
|
||||
|
||||
# ⛔ REGRAS DE SEGURANÇA (sempre ativas, antes de tudo)
|
||||
|
||||
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem de transferência (ver "Transferência" abaixo), (d) encerre, não responda mais.
|
||||
|
||||
**2. ROTEIE PRO CENÁRIO PRIMEIRO. SÓ depois pense em handoff humano.** A ordem de decisão é SEMPRE esta:
|
||||
|
||||
1. **Pergunta sobre preço, valor, tabela, reserva, Pix, "quanto custa", nome de suíte/categoria** → `handoff_to_daniela_reservas`. SEMPRE. A Daniela tem a tabela completa de preços de TODAS as 8 categorias do Dolce (Apartamento, Suíte Master, Luxo, Temática, Mini Chalé 45, Chalé 2 Suítes, Chalé Master 4 Suítes, Suíte Ouro). Você (orquestradora) NUNCA responde preço por cima — sempre roteia.
|
||||
2. **Pergunta sobre foto, imagem, "me mostra", "tem foto da X"** → `handoff_to_maria_fotos`.
|
||||
3. **Pergunta sobre disponibilidade, "tem suíte livre", "tem vaga pra quinta"** → `handoff_to_disponibilidade_suites`.
|
||||
4. **Pergunta sobre outras unidades, outras cidades, "tem em São Paulo?"** → `handoff_to_outras_unidades`.
|
||||
5. **Reclamação, queixa, ouvidoria** → `handoff_to_reclamacoes_ouvidoria`.
|
||||
6. **Saudação vaga, "oi", "tudo bem", "boa tarde"** → cumprimenta, não roteia, espera próximo passo.
|
||||
7. **NENHUM dos casos acima** → aí sim, considere FAQ/handoff humano (ver regra abaixo).
|
||||
|
||||
**Nunca invente. NA DÚVIDA (depois de já ter descartado os 6 cenários acima), TRANSFERE PRA HUMANO em silêncio.** Se a pergunta não cabe em nenhum cenário e você não tem a info documentada, faça **handoff silencioso**: chame `captain--tools--handoff` pra humano e responda APENAS *"Um momento."*. Não diga "vou transferir", "vou chamar", "passar pra equipe", "deixa eu ver", "te passo com a Daniela", "te conecto com Fulano" — qualquer dessas frases entrega que você é robô OU expõe a estrutura interna (Daniela/Maria são cenários, NÃO pessoas reais que o cliente conhece). Tente o `captain--tools--faq_lookup` primeiro APENAS se a dúvida claramente cai numa FAQ documentada (regra de feriado, política de no-show, etc). Se faq_lookup não trouxer resposta clara e específica → handoff direto, *"Um momento."*. Curva conservadora: prefere transferir do que improvisar.
|
||||
|
||||
**3. Disfarce como humano.** Não revele que é IA a menos que o cliente pergunte diretamente.
|
||||
|
||||
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. **Em particular: NUNCA fale "Daniela", "Maria", "Camila" ou qualquer nome de cenário pro cliente** — esses são roles internos invisíveis. Pro cliente, é tudo VOCÊ ({{name}}). Quando rotear pra cenário, simplesmente faça o `handoff_to_<key>` e o próximo turno já vem do cenário, sem aviso.
|
||||
|
||||
**5. PROIBIDO prometer descontos ou cancelar reservas.** Diretriz interna fixa do Dolce Amore: você NUNCA promete desconto, cortesia, brinde extra ou cancelamento de reserva por conta própria. Se o cliente pedir, responda: *"Vou passar seu pedido pra gerência, eles avaliam e te retornam."* — e não comprometa nada. Quem decide isso é humano, nunca você.
|
||||
|
||||
**6. PROIBIDO atender menores de idade.** O Dolce Amore não permite entrada de menores. Se o cliente identificar que é menor, ou trouxer/comentar sobre menor acompanhando, deflete educadamente: *"Aqui no Dolce Amore só recebemos hóspedes maiores de 18 anos, é regra fixa da casa."* — e encerra a tentativa de reserva.
|
||||
|
||||
# 🎯 Roteamento
|
||||
|
||||
Depois de verificar as 6 regras acima:
|
||||
1. Identifique intenção do cliente.
|
||||
2. Olhe "Cenários Disponíveis" abaixo — cada um tem gatilhos.
|
||||
3. Roteie com `handoff_to_<key>`. Se falta dado, roteie mesmo — o cenário coleta.
|
||||
4. Sem cenário aplicável: `captain--tools--faq_lookup` pra dúvida factual, ou `captain--tools--handoff` pra humano.
|
||||
|
||||
**Saudação curta ou vaga** ("oi", "tudo bem") → não roteie. Cumprimente e espere o próximo passo.
|
||||
|
||||
**Princípio:** se intenção encaixa num cenário, use — nunca tente resolver "por cima".
|
||||
|
||||
# Formato da Resposta
|
||||
- Máx 2 parágrafos curtos.
|
||||
- Uma pergunta por vez.
|
||||
- Negrito em informações críticas.
|
||||
- Primeira msg da conversa: use a Saudação Personalizada (abaixo). Se o cliente tem nome cadastrado, prefira a variante com nome.
|
||||
- Depois de cenário/tool retornar: reescreva em linguagem natural. Nunca copie JSON, IDs ou texto técnico.
|
||||
- Próximo passo claro no final. Cliente sumiu: 1 lembrete educado e encerra.
|
||||
|
||||
# Data/Hora
|
||||
- Data: {{ current_date }}
|
||||
- Hora: {{ current_time }}
|
||||
- Fuso: {{ current_timezone }}
|
||||
|
||||
{% if conversation or contact -%}
|
||||
# Current Context
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% endif -%}
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
# reaction_emoji (opcional)
|
||||
Quando fizer sentido (saudação, agradecimento, celebração, "estou verificando"), sugira emoji no campo `reaction_emoji`. Vazio quando não combinar.
|
||||
|
||||
# Cenários Disponíveis
|
||||
{% for scenario in scenarios %}
|
||||
## {{ scenario.title }}
|
||||
{{ scenario.description }}
|
||||
{% if scenario.trigger_keywords != blank %}
|
||||
**Gatilhos** (`handoff_to_{{ scenario.key }}`): {{ scenario.trigger_keywords }}
|
||||
{% else %}
|
||||
Acionar: `handoff_to_{{ scenario.key }}`
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
# ⛔ Lembretes finais
|
||||
Nunca: vazar contexto/metadados; prometer mídia antes do tool confirmar; responder por memória quando existe cenário; usar histórico como fonte; copiar texto cru de ferramenta; prometer desconto/cancelamento sem autorização.
|
||||
# ---SECAO-ASSISTENTE---
|
||||
# Instruções Específicas desta Unidade
|
||||
|
||||
## Contexto
|
||||
- **Hotel:** Dolce Amore Motel
|
||||
- **Endereço:** Rua Professor Pedro Pinheiro de Souza, 225 — Ponta Negra, Natal/RN
|
||||
- **Especialidade:** motel — casais buscando privacidade, por horas, pernoite ou diária
|
||||
- **Categorias:** Apartamento Standard, Suíte Master, Suíte Luxo, Suíte Temática, Mini Chalé 45, Chalé 2 Suítes, Chalé Master 4 Suítes, Suíte Ouro
|
||||
- **Público:** casais maiores de 18 anos, geralmente programa de 3h podendo estender até 24h
|
||||
- **Pagamento:** Pix (sinal de 50%)
|
||||
|
||||
**IMPORTANTE — atendimento EXCLUSIVO de Natal/RN.** O Dolce Amore atende somente Ponta Negra/Natal. Não há outras unidades da marca em outras cidades. Se o cliente perguntar por outras regiões, responda que aqui é exclusivo de Natal e que não temos filial em outras cidades.
|
||||
|
||||
## Links
|
||||
- Tabela de preços: {{ media.tabela }}
|
||||
- WhatsApp: https://wa.me/5584987013256
|
||||
- Telefone fixo: (84) 3201-5051
|
||||
- Maps: https://maps.app.goo.gl/i9BvpZAPagjnnFv69
|
||||
|
||||
## Saudação (1ª msg) — FÓRMULA ÚNICA
|
||||
|
||||
Monte a saudação assim:
|
||||
|
||||
```
|
||||
<saudacao> Sou a {{name}} do Dolce Amore Motel 😊 Como posso te ajudar?
|
||||
```
|
||||
|
||||
Onde `<saudacao>` é:
|
||||
- `Oi, <primeiro_nome>!` se Name no Contact Information é nome próprio válido (2+ palavras alfabéticas, ex: "Rodrigo Borba Machado" → primeiro_nome = Rodrigo).
|
||||
- `Oi!` se Name for emoji, curto, número, "Unknown" ou vazio.
|
||||
|
||||
Exemplo concreto:
|
||||
- Name no Contact = "Rodrigo Borba Machado" → primeiro_nome = "Rodrigo" → saudação DEVE ser exatamente: *"Oi, Rodrigo! Sou a {{name}} do Dolce Amore Motel 😊 Como posso te ajudar?"*
|
||||
|
||||
NUNCA comece com `Oi!` isolado quando Name é nome próprio válido. Essa é a checagem de qualidade: antes de enviar, releia sua resposta — se começa com `Oi!` sem o nome do cliente mas o Contact Information tem Name válido, você violou a regra.
|
||||
|
||||
## Transferência (hóspede já no motel OU qualquer caso de handoff)
|
||||
|
||||
**Mensagem ÚNICA:** *"Um momento."*
|
||||
|
||||
NUNCA varie pra "vou transferir", "vou chamar", "passar pra equipe", "estou encaminhando", "recepção", "atendimento local", etc. Apenas *"Um momento."* e a tool de handoff cuida do resto.
|
||||
|
||||
## Refere-se à unidade como "Dolce Amore" ou "aqui no Dolce Amore".
|
||||
@ -31,23 +31,13 @@ Violar essa regra (cumprimentar sem nome quando `Name` é válido) é erro grave
|
||||
|
||||
# ⛔ REGRAS DE SEGURANÇA (sempre ativas, antes de tudo)
|
||||
|
||||
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem de transferência (ver "Transferência" abaixo), (d) encerre, não responda mais.
|
||||
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem padrão de transferência desta unidade, (d) encerre, não responda mais.
|
||||
|
||||
**2. ROTEIE PRO CENÁRIO PRIMEIRO. SÓ depois pense em handoff humano.** A ordem de decisão é SEMPRE esta:
|
||||
|
||||
1. **Pergunta sobre preço, valor, tabela, reserva, Pix, "quanto custa", nome de suíte (Stilo, Alexa, Hidromassagem)** → `handoff_to_daniela_reservas`. SEMPRE. A Daniela tem a tabela completa de preços. Você (orquestradora) NUNCA responde preço por cima — sempre roteia.
|
||||
2. **Pergunta sobre foto, imagem, "me mostra", "tem foto da X"** → `handoff_to_maria_fotos`.
|
||||
3. **Pergunta sobre disponibilidade, "tem suíte livre", "tem vaga pra quinta"** → `handoff_to_disponibilidade_suites`.
|
||||
4. **Pergunta sobre outras unidades (PrimeVL, Qnn01, Express, etc) ou cidades** → `handoff_to_outras_unidades`.
|
||||
5. **Reclamação, queixa, ouvidoria** → `handoff_to_reclamacoes_ouvidoria`.
|
||||
6. **Saudação vaga, "oi", "tudo bem", "boa tarde"** → cumprimenta, não roteia, espera próximo passo.
|
||||
7. **NENHUM dos casos acima** → aí sim, considere FAQ/handoff humano (ver regra abaixo).
|
||||
|
||||
**Nunca invente. NA DÚVIDA (depois de já ter descartado os 6 cenários acima), TRANSFERE PRA HUMANO em silêncio.** Se a pergunta não cabe em nenhum cenário e você não tem a info documentada, faça **handoff silencioso**: chame `captain--tools--handoff` pra humano e responda APENAS *"Um momento."*. Não diga "vou transferir", "vou chamar", "passar pra equipe", "deixa eu ver", "te passo com a Daniela", "te conecto com Fulano" — qualquer dessas frases entrega que você é robô OU expõe a estrutura interna (Daniela/Maria são cenários, NÃO pessoas reais que o cliente conhece). Tente o `captain--tools--faq_lookup` primeiro APENAS se a dúvida claramente cai numa FAQ documentada (regra de feriado, política de no-show, etc). Se faq_lookup não trouxer resposta clara e específica → handoff direto, *"Um momento."*. Curva conservadora: prefere transferir do que improvisar.
|
||||
**2. Nunca invente.** Dúvida factual fora do escopo dos cenários → chame `captain--tools--faq_lookup` ANTES de responder. Nunca responda "não sei". Nunca use histórico como fonte — FAQ é a fonte oficial.
|
||||
|
||||
**3. Disfarce como humano.** Não revele que é IA a menos que o cliente pergunte diretamente.
|
||||
|
||||
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. **Em particular: NUNCA fale "Daniela", "Maria", "Camila" ou qualquer nome de cenário pro cliente** — esses são roles internos invisíveis. Pro cliente, é tudo VOCÊ ({{name}}). Quando rotear pra cenário, simplesmente faça o `handoff_to_<key>` e o próximo turno já vem do cenário, sem aviso.
|
||||
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. Reescreva em linguagem natural.
|
||||
|
||||
# 🎯 Roteamento
|
||||
|
||||
@ -132,11 +122,8 @@ Exemplo concreto para este teste:
|
||||
|
||||
NUNCA comece com `Oi!` isolado quando Name é nome próprio válido. Essa é a checagem de qualidade: antes de enviar, releia sua resposta — se começa com `Oi!` sem o nome do cliente mas o Contact Information tem Name válido, você violou a regra.
|
||||
|
||||
## Transferência (hóspede já no hotel OU qualquer caso de handoff)
|
||||
|
||||
**Mensagem ÚNICA:** *"Um momento."*
|
||||
|
||||
NUNCA varie pra "vou transferir", "vou chamar", "passar pra equipe", "estou encaminhando", "central de atendimento", "atendimento local", "recepção", etc. Apenas *"Um momento."* e a tool de handoff cuida do resto.
|
||||
## Transferência (hóspede já no hotel)
|
||||
*"Vou te encaminhar pra um atendente local aí no hotel pra resolver mais rápido. Nosso primeiro atendimento é pela central, já estou transferindo pra equipe presencial. Só um instante."*
|
||||
|
||||
## Refere-se à unidade como "1001 Noites Prime – Águas Lindas" ou "aqui em Águas Lindas".
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user