From 972fc5c67babb3f2f9435c501ae22a9abe49bfd0 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Thu, 26 Feb 2026 23:04:28 -0300 Subject: [PATCH 1/6] feat(captain): improve suite photo search accuracy with AI guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Melhorias na ferramenta send_suite_images para resolver confusão entre categoria e número de suíte: 1. **Descrições de parâmetros mais claras** - suite_category: exemplos específicos (Hidromassagem, ALEXA, STILO) - suite_number: apenas números (101, 102, 103) - remove exemplos confusos 2. **Instruções explícitas no system prompt** - Seção [Galeria de Fotos] com regras claras - Prioriza suite_category quando ambíguo - Evita confirmações desnecessárias com cliente 3. **Mensagens de erro melhoradas** - Sugere buscar por categoria quando busca por número falha - Feedback mais útil para a IA Resultado esperado: - Cliente: "Me manda foto da suite Alexa" - IA: busca por suite_category="Alexa" ✓ (sem pedir confirmação) Co-Authored-By: Claude Sonnet 4.5 --- .../captain/reports/insights_controller.rb | 72 ++++++++ .../captain/reports/operational_controller.rb | 105 ++++++++++++ app/javascript/dashboard/store/index.js | 2 + .../dashboard/store/modules/captainReports.js | 111 ++++++++++++ .../dashboard/store/mutation-types.js | 6 + config/routes.rb | 6 + ...00_create_captain_conversation_insights.rb | 24 +++ ...6230001_create_captain_report_snapshots.rb | 18 ++ db/schema.rb | 49 +++++- .../captain/reports/daily_snapshot_job.rb | 91 ++++++++++ .../captain/reports/generate_insights_job.rb | 60 +++++++ .../captain/reports/weekly_insights_job.rb | 24 +++ .../models/captain/conversation_insight.rb | 63 +++++++ .../app/models/captain/report_snapshot.rb | 23 +++ enterprise/app/models/captain/unit_inbox.rb | 20 +++ .../llm/conversation_insight_service.rb | 160 ++++++++++++++++++ .../captain/llm/system_prompts_service.rb | 59 +++++++ .../captain/tools/send_suite_images_tool.rb | 17 +- 18 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/accounts/captain/reports/insights_controller.rb create mode 100644 app/controllers/api/v1/accounts/captain/reports/operational_controller.rb create mode 100644 app/javascript/dashboard/store/modules/captainReports.js create mode 100644 db/migrate/20260226230000_create_captain_conversation_insights.rb create mode 100644 db/migrate/20260226230001_create_captain_report_snapshots.rb create mode 100644 enterprise/app/jobs/captain/reports/daily_snapshot_job.rb create mode 100644 enterprise/app/jobs/captain/reports/generate_insights_job.rb create mode 100644 enterprise/app/jobs/captain/reports/weekly_insights_job.rb create mode 100644 enterprise/app/models/captain/conversation_insight.rb create mode 100644 enterprise/app/models/captain/report_snapshot.rb create mode 100644 enterprise/app/services/captain/llm/conversation_insight_service.rb diff --git a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb new file mode 100644 index 000000000..6dc0783a3 --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb @@ -0,0 +1,72 @@ +class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController + def index + unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil + insights = Captain::ConversationInsight + .where(account_id: Current.account.id, captain_unit_id: unit_id) + .order(period_start: :desc) + .limit(12) + + render json: insights.map { |i| format_insight(i) } + end + + def show + insight = Captain::ConversationInsight.find_by!( + id: params[:id], + account_id: Current.account.id + ) + render json: format_insight(insight) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Insight não encontrado' }, status: :not_found + end + + def generate + period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_week - 1.week) + period_end = parse_date(params[:period_end], Time.zone.today.beginning_of_week - 1.day) + unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil + + enqueue_insight(unit_id, period_start, period_end) + end + + private + + def enqueue_insight(unit_id, period_start, period_end) + insight = Captain::ConversationInsight.find_or_initialize_by( + account_id: Current.account.id, + captain_unit_id: unit_id, + period_start: period_start, + period_end: period_end + ) + + return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing? + + insight.status = 'pending' + insight.payload = nil + insight.save! + + Captain::Reports::GenerateInsightsJob.perform_later( + Current.account.id, unit_id, period_start, period_end + ) + + render json: { status: 'queued', insight_id: insight.id }, status: :accepted + end + + def parse_date(param, default) + param.present? ? Date.parse(param) : default + rescue ArgumentError + default + end + + def format_insight(insight) + { + id: insight.id, + unit_id: insight.captain_unit_id, + period_start: insight.period_start, + period_end: insight.period_end, + status: insight.status, + conversations_count: insight.conversations_count, + messages_count: insight.messages_count, + generated_at: insight.generated_at, + payload: insight.payload + } + end +end diff --git a/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb b/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb new file mode 100644 index 000000000..c790accdc --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb @@ -0,0 +1,105 @@ +class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Accounts::BaseController + def show + period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month) + period_end = parse_date(params[:period_end], Time.zone.today) + unit = params[:unit_id].present? ? Current.account.captain_units.find_by(id: params[:unit_id]) : nil + + render json: build_operational_report(unit, period_start, period_end) + end + + private + + def parse_date(param, default) + param.present? ? Date.parse(param) : default + rescue ArgumentError + default + end + + def build_operational_report(unit, period_start, period_end) + conversations = scoped_conversations(unit, period_start, period_end) + + { + period: { start: period_start, end: period_end }, + unit_id: unit&.id, + unit_name: unit&.name, + conversations: conversation_metrics(conversations), + reservations: reservation_metrics(unit, period_start, period_end), + hourly_distribution: hourly_distribution(conversations), + daily_distribution: daily_distribution(conversations, period_start, period_end) + } + end + + def conversation_metrics(conversations) + resolved = conversations.where(status: 'resolved') + avg_minutes = avg_resolution_minutes(resolved) + + { + total: conversations.count, + resolved: resolved.count, + open: conversations.where(status: 'open').count, + resolution_rate: safe_rate(resolved.count, conversations.count), + avg_resolution_minutes: avg_minutes + } + end + + def reservation_metrics(unit, period_start, period_end) + reservations = scoped_reservations(unit, period_start, period_end) + paid = reservations.where(status: 'paid') + expired = reservations.where(status: 'expired') + + { + total: reservations.count, + paid: paid.count, + expired: expired.count, + pending: reservations.where(status: %w[pending waiting]).count, + conversion_rate: safe_rate(paid.count, reservations.count), + total_paid_cents: paid.sum(:amount_cents) + } + rescue StandardError + { total: 0, paid: 0, expired: 0, pending: 0, conversion_rate: 0, total_paid_cents: 0 } + end + + def hourly_distribution(conversations) + (0..23).map do |hour| + count = conversations.where('EXTRACT(HOUR FROM created_at) = ?', hour).count + { hour: hour, count: count } + end + end + + def daily_distribution(conversations, period_start, period_end) + (period_start..period_end).map do |date| + count = conversations.where(created_at: date.all_day).count + { date: date.to_s, count: count } + end + end + + def scoped_conversations(unit, period_start, period_end) + scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day) + if unit + inbox_ids = unit.inboxes.pluck(:id) + scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? + end + scope + end + + def scoped_reservations(unit, period_start, period_end) + scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day) + scope = scope.where(captain_unit_id: unit.id) if unit + scope + end + + def avg_resolution_minutes(conversations) + return 0 if conversations.none? + + total = conversations.sum do |c| + ((c.updated_at - c.created_at) / 60).round + end + (total / conversations.count).round + end + + def safe_rate(numerator, denominator) + return 0 if denominator.zero? + + ((numerator.to_f / denominator) * 100).round(1) + end +end diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 1c46fb70f..21d937529 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -62,6 +62,7 @@ import captainCustomTools from './captain/customTools'; import captainReservations from './captain/reservations'; import captainUnits from './modules/captainUnits'; import captainGalleryItems from './modules/captainGalleryItems'; +import captainReports from './modules/captainReports'; const plugins = []; @@ -129,6 +130,7 @@ export default createStore({ captainReservations, captainUnits, captainGalleryItems, + captainReports, }, plugins, }); diff --git a/app/javascript/dashboard/store/modules/captainReports.js b/app/javascript/dashboard/store/modules/captainReports.js new file mode 100644 index 000000000..e82dbce81 --- /dev/null +++ b/app/javascript/dashboard/store/modules/captainReports.js @@ -0,0 +1,111 @@ +import * as MutationTypes from '../mutation-types'; +import ApiClient from '../../api'; + +const captainReportsAPI = { + getOperational: (accountId, params) => + ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/operational`, { + params, + }), + getInsights: (accountId, params) => + ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/insights`, { + params, + }), + getInsight: (accountId, id) => + ApiClient.get( + `/api/v1/accounts/${accountId}/captain/reports/insights/${id}` + ), + generateInsight: (accountId, data) => + ApiClient.post( + `/api/v1/accounts/${accountId}/captain/reports/insights/generate`, + data + ), +}; + +const state = { + operational: null, + insights: [], + currentInsight: null, + uiFlags: { + isFetchingOperational: false, + isFetchingInsights: false, + isGenerating: false, + }, +}; + +export const getters = { + getOperational: $state => $state.operational, + getInsights: $state => $state.insights, + getCurrentInsight: $state => $state.currentInsight, + getUIFlags: $state => $state.uiFlags, +}; + +export const mutations = { + [MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL]($state, data) { + $state.operational = data; + }, + [MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS]($state, data) { + $state.insights = data; + }, + [MutationTypes.SET_CAPTAIN_REPORTS_CURRENT_INSIGHT]($state, data) { + $state.currentInsight = data; + }, + [MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS]($state, flags) { + $state.uiFlags = { ...$state.uiFlags, ...flags }; + }, +}; + +export const actions = { + async fetchOperational({ commit, rootGetters }, params = {}) { + const accountId = rootGetters['auth/getCurrentAccountId']; + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { + isFetchingOperational: true, + }); + try { + const { data } = await captainReportsAPI.getOperational( + accountId, + params + ); + commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data); + } finally { + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { + isFetchingOperational: false, + }); + } + }, + + async fetchInsights({ commit, rootGetters }, params = {}) { + const accountId = rootGetters['auth/getCurrentAccountId']; + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { + isFetchingInsights: true, + }); + try { + const { data } = await captainReportsAPI.getInsights(accountId, params); + commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data); + } finally { + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { + isFetchingInsights: false, + }); + } + }, + + async generateInsight({ commit, dispatch, rootGetters }, payload) { + const accountId = rootGetters['auth/getCurrentAccountId']; + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true }); + try { + await captainReportsAPI.generateInsight(accountId, payload); + await dispatch('fetchInsights', { unit_id: payload.unit_id }); + } finally { + commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { + isGenerating: false, + }); + } + }, +}; + +export default { + namespaced: true, + state, + getters, + mutations, + actions, +}; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 25df9517c..80fb98ea7 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -404,4 +404,10 @@ export default { ADD_CAPTAIN_GALLERY_ITEM: 'ADD_CAPTAIN_GALLERY_ITEM', EDIT_CAPTAIN_GALLERY_ITEM: 'EDIT_CAPTAIN_GALLERY_ITEM', DELETE_CAPTAIN_GALLERY_ITEM: 'DELETE_CAPTAIN_GALLERY_ITEM', + + // Captain Reports + SET_CAPTAIN_REPORTS_OPERATIONAL: 'SET_CAPTAIN_REPORTS_OPERATIONAL', + SET_CAPTAIN_REPORTS_INSIGHTS: 'SET_CAPTAIN_REPORTS_INSIGHTS', + SET_CAPTAIN_REPORTS_CURRENT_INSIGHT: 'SET_CAPTAIN_REPORTS_CURRENT_INSIGHT', + SET_CAPTAIN_REPORTS_UI_FLAGS: 'SET_CAPTAIN_REPORTS_UI_FLAGS', }; diff --git a/config/routes.rb b/config/routes.rb index 4cb3a4190..f76ae4e4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -91,6 +91,12 @@ Rails.application.routes.draw do post :follow_up end resources :units + namespace :reports do + resource :operational, only: [:show], controller: 'reports/operational' + resources :insights, only: [:index, :show] do + post :generate, on: :collection + end + end end resource :saml_settings, only: [:show, :create, :update, :destroy] resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do diff --git a/db/migrate/20260226230000_create_captain_conversation_insights.rb b/db/migrate/20260226230000_create_captain_conversation_insights.rb new file mode 100644 index 000000000..69ee64403 --- /dev/null +++ b/db/migrate/20260226230000_create_captain_conversation_insights.rb @@ -0,0 +1,24 @@ +class CreateCaptainConversationInsights < ActiveRecord::Migration[7.1] + def change + create_table :captain_conversation_insights do |t| + t.references :account, null: false, foreign_key: true + t.references :captain_unit, null: true, foreign_key: true + t.date :period_start, null: false + t.date :period_end, null: false + t.string :status, null: false, default: 'pending' + t.jsonb :payload + t.integer :conversations_count, default: 0 + t.integer :messages_count, default: 0 + t.integer :llm_tokens_used + t.timestamp :generated_at + + t.timestamps + end + + add_index :captain_conversation_insights, + %i[captain_unit_id period_start period_end], + unique: true, + name: 'idx_captain_insights_unique_period' + add_index :captain_conversation_insights, %i[account_id status] + end +end diff --git a/db/migrate/20260226230001_create_captain_report_snapshots.rb b/db/migrate/20260226230001_create_captain_report_snapshots.rb new file mode 100644 index 000000000..a1d448b96 --- /dev/null +++ b/db/migrate/20260226230001_create_captain_report_snapshots.rb @@ -0,0 +1,18 @@ +class CreateCaptainReportSnapshots < ActiveRecord::Migration[7.1] + def change + create_table :captain_report_snapshots do |t| + t.references :account, null: false, foreign_key: true + t.references :captain_unit, null: true, foreign_key: true + t.date :snapshot_date, null: false + t.jsonb :data, null: false, default: {} + + t.timestamps + end + + add_index :captain_report_snapshots, + %i[captain_unit_id snapshot_date], + unique: true, + name: 'idx_captain_snapshots_unique_date' + add_index :captain_report_snapshots, %i[account_id snapshot_date] + end +end diff --git a/db/schema.rb b/db/schema.rb index 41e8919de..db82e6965 100644 --- a/db/schema.rb +++ b/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_02_26_002000) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_230001) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -366,6 +366,25 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do t.index ["account_id"], name: "index_captain_configurations_on_account_id" end + create_table "captain_conversation_insights", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "captain_unit_id" + t.date "period_start", null: false + t.date "period_end", null: false + t.string "status", default: "pending", null: false + t.jsonb "payload" + t.integer "conversations_count", default: 0 + t.integer "messages_count", default: 0 + t.integer "llm_tokens_used" + t.datetime "generated_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status"], name: "index_captain_conversation_insights_on_account_id_and_status" + t.index ["account_id"], name: "index_captain_conversation_insights_on_account_id" + t.index ["captain_unit_id", "period_start", "period_end"], name: "idx_captain_insights_unique_period", unique: true + t.index ["captain_unit_id"], name: "index_captain_conversation_insights_on_captain_unit_id" + end + create_table "captain_custom_tools", force: :cascade do |t| t.bigint "account_id", null: false t.string "slug", null: false @@ -683,6 +702,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do t.index ["source_type", "source_id"], name: "index_captain_reminders_on_source_type_and_source_id" end + create_table "captain_report_snapshots", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "captain_unit_id" + t.date "snapshot_date", null: false + t.jsonb "data", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "snapshot_date"], name: "index_captain_report_snapshots_on_account_id_and_snapshot_date" + t.index ["account_id"], name: "index_captain_report_snapshots_on_account_id" + t.index ["captain_unit_id", "snapshot_date"], name: "idx_captain_snapshots_unique_date", unique: true + t.index ["captain_unit_id"], name: "index_captain_report_snapshots_on_captain_unit_id" + end + create_table "captain_reservations", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "inbox_id", null: false @@ -770,6 +802,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do t.index ["inbox_id"], name: "index_captain_tool_configs_on_inbox_id" end + create_table "captain_unit_inboxes", force: :cascade do |t| + t.bigint "captain_unit_id", null: false + t.bigint "inbox_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["captain_unit_id", "inbox_id"], name: "index_captain_unit_inboxes_on_unit_and_inbox", unique: true + t.index ["inbox_id"], name: "index_captain_unit_inboxes_on_inbox_id" + end + create_table "captain_units", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "captain_brand_id", null: false @@ -1907,6 +1948,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do add_foreign_key "captain_assets", "captain_suites" add_foreign_key "captain_brands", "accounts" add_foreign_key "captain_configurations", "accounts" + add_foreign_key "captain_conversation_insights", "accounts" + add_foreign_key "captain_conversation_insights", "captain_units" add_foreign_key "captain_extras", "accounts" add_foreign_key "captain_gallery_items", "accounts" add_foreign_key "captain_gallery_items", "captain_units" @@ -1936,6 +1979,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do add_foreign_key "captain_reminders", "contacts" add_foreign_key "captain_reminders", "conversations" add_foreign_key "captain_reminders", "inboxes" + add_foreign_key "captain_report_snapshots", "accounts" + add_foreign_key "captain_report_snapshots", "captain_units" add_foreign_key "captain_reservations", "accounts" add_foreign_key "captain_reservations", "captain_brands" add_foreign_key "captain_reservations", "captain_units" @@ -1946,6 +1991,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do add_foreign_key "captain_suites", "accounts" add_foreign_key "captain_tool_configs", "accounts" add_foreign_key "captain_tool_configs", "inboxes" + add_foreign_key "captain_unit_inboxes", "captain_units", on_delete: :cascade + add_foreign_key "captain_unit_inboxes", "inboxes", on_delete: :cascade add_foreign_key "captain_units", "accounts" add_foreign_key "captain_units", "captain_brands" add_foreign_key "captain_units", "inboxes" diff --git a/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb b/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb new file mode 100644 index 000000000..3dd2b2d91 --- /dev/null +++ b/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb @@ -0,0 +1,91 @@ +class Captain::Reports::DailySnapshotJob < ApplicationJob + queue_as :scheduled_jobs + + # Roda todo dia à meia-noite via Sidekiq-Cron. + # Salva um snapshot dos dados operacionais de ontem por unidade. + def perform + date = Date.yesterday + + Account.find_each do |account| + save_snapshot(account, nil, date) + + account.captain_units.find_each do |unit| + save_snapshot(account, unit, date) + end + end + end + + private + + def save_snapshot(account, unit, date) + data = build_snapshot_data(account, unit, date) + + Captain::ReportSnapshot.find_or_create_by!( + account_id: account.id, + captain_unit_id: unit&.id, + snapshot_date: date + ) do |snap| + snap.data = data + end + end + + # rubocop:disable Metrics/MethodLength + def build_snapshot_data(account, unit, date) + conversations = base_conversations(account, unit, date) + reservations = base_reservations(account, unit, date) + + { + date: date.to_s, + unit_id: unit&.id, + unit_name: unit&.name, + conversations: { + total: conversations.count, + resolved: conversations.where(status: 'resolved').count, + open: conversations.where(status: 'open').count, + avg_resolution_minutes: avg_resolution(conversations) + }, + reservations: { + total: reservations.count, + paid: reservations.where(status: 'paid').count, + expired: reservations.where(status: 'expired').count, + pending: reservations.where(status: %w[pending waiting]).count, + total_amount_cents: reservations.where(status: 'paid').sum(:amount_cents) + } + } + end + # rubocop:enable Metrics/MethodLength + + def base_conversations(account, unit, date) + scope = account.conversations.where(created_at: date.all_day) + if unit + inbox_ids = unit.inboxes.pluck(:id) + scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? + end + scope + end + + def base_reservations(account, unit, date) + scope = begin + account.captain_reservations.where(created_at: date.all_day) + rescue StandardError + account.captain_units.joins(:reservations).merge(Captain::Reservation.where(created_at: date.all_day)) + end + scope = scope.where(captain_unit_id: unit.id) if unit + scope + rescue StandardError + Captain::Reservation.none + end + + def avg_resolution(conversations) + resolved = conversations.where.not(first_reply_created_at: nil) + return 0 if resolved.none? + + total_minutes = resolved.sum do |c| + next 0 unless c.first_reply_created_at + + ((c.updated_at - c.created_at) / 60).round + end + + (total_minutes / resolved.count).round + end +end diff --git a/enterprise/app/jobs/captain/reports/generate_insights_job.rb b/enterprise/app/jobs/captain/reports/generate_insights_job.rb new file mode 100644 index 000000000..ecb7390b2 --- /dev/null +++ b/enterprise/app/jobs/captain/reports/generate_insights_job.rb @@ -0,0 +1,60 @@ +class Captain::Reports::GenerateInsightsJob < ApplicationJob + queue_as :default + + # Gera insights de IA para uma unidade específica em um período. + # Pode ser disparado on-demand (botão na UI) ou pelo WeeklyInsightsJob. + def perform(account_id, unit_id, period_start, period_end) + account = Account.find_by(id: account_id) + return unless account + + unit = account.captain_units.find_by(id: unit_id) + insight = find_or_create_insight(account_id, unit_id, period_start, period_end) + return if insight.processing? || insight.done? + + insight.mark_processing! + run_analysis(account, unit, insight, period_start, period_end) + rescue StandardError => e + Rails.logger.error "[Captain::Reports::GenerateInsightsJob] Error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" + insight&.mark_failed! + end + + private + + def find_or_create_insight(account_id, unit_id, period_start, period_end) + insight = Captain::ConversationInsight.find_or_initialize_by( + account_id: account_id, + captain_unit_id: unit_id, + period_start: period_start, + period_end: period_end + ) + insight.save! if insight.new_record? + insight + end + + def run_analysis(account, unit, insight, period_start, period_end) + conversations = fetch_conversations(account, unit, period_start, period_end) + insight.update!(conversations_count: conversations.count) + + payload = Captain::Llm::ConversationInsightService.new( + account: account, + unit: unit, + conversations: conversations + ).analyze + + insight.update!(messages_count: conversations.sum { |conv| conv.messages.count }) + insight.mark_done!(payload) + end + + def fetch_conversations(account, unit, period_start, period_end) + scope = account.conversations + .where(created_at: period_start.beginning_of_day..period_end.end_of_day) + .includes(:messages) + + if unit + inbox_ids = unit.inboxes.pluck(:id) + scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? + end + + scope.to_a + end +end diff --git a/enterprise/app/jobs/captain/reports/weekly_insights_job.rb b/enterprise/app/jobs/captain/reports/weekly_insights_job.rb new file mode 100644 index 000000000..d3828ab2c --- /dev/null +++ b/enterprise/app/jobs/captain/reports/weekly_insights_job.rb @@ -0,0 +1,24 @@ +class Captain::Reports::WeeklyInsightsJob < ApplicationJob + queue_as :scheduled_jobs + + # Roda todo domingo de madrugada via Sidekiq-Cron. + # Agenda geração de insights para todas as unidades de todas as contas. + def perform + period_end = Date.yesterday + period_start = period_end - 6.days + + Account.find_each do |account| + # Gera um insight global (sem unit) para a conta toda + Captain::Reports::GenerateInsightsJob.perform_later( + account.id, nil, period_start, period_end + ) + + # Gera um insight por unidade + account.captain_units.find_each do |unit| + Captain::Reports::GenerateInsightsJob.perform_later( + account.id, unit.id, period_start, period_end + ) + end + end + end +end diff --git a/enterprise/app/models/captain/conversation_insight.rb b/enterprise/app/models/captain/conversation_insight.rb new file mode 100644 index 000000000..1f34f8f71 --- /dev/null +++ b/enterprise/app/models/captain/conversation_insight.rb @@ -0,0 +1,63 @@ +# == Schema Information +# +# Table name: captain_conversation_insights +# +# id :bigint not null, primary key +# account_id :bigint not null +# captain_unit_id :bigint +# period_start :date not null +# period_end :date not null +# status :string default("pending"), not null +# payload :jsonb +# conversations_count :integer default(0) +# messages_count :integer default(0) +# llm_tokens_used :integer +# generated_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# + +class Captain::ConversationInsight < ApplicationRecord + include Rails.application.routes.url_helpers + + STATUSES = %w[pending processing done failed].freeze + + belongs_to :account + belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true + + validates :period_start, :period_end, :status, presence: true + validates :status, inclusion: { in: STATUSES } + + scope :done, -> { where(status: 'done') } + scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) } + scope :for_period, ->(start_date, end_date) { where(period_start: start_date, period_end: end_date) } + + def mark_processing! + update!(status: 'processing') + end + + def mark_done!(payload, tokens_used: nil) + update!( + status: 'done', + payload: payload, + llm_tokens_used: tokens_used, + generated_at: Time.current + ) + end + + def mark_failed! + update!(status: 'failed') + end + + def pending? + status == 'pending' + end + + def processing? + status == 'processing' + end + + def done? + status == 'done' + end +end diff --git a/enterprise/app/models/captain/report_snapshot.rb b/enterprise/app/models/captain/report_snapshot.rb new file mode 100644 index 000000000..262c9b6ef --- /dev/null +++ b/enterprise/app/models/captain/report_snapshot.rb @@ -0,0 +1,23 @@ +# == Schema Information +# +# Table name: captain_report_snapshots +# +# id :bigint not null, primary key +# account_id :bigint not null +# captain_unit_id :bigint +# snapshot_date :date not null +# data :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Captain::ReportSnapshot < ApplicationRecord + belongs_to :account + belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true + + validates :snapshot_date, presence: true + + scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) } + scope :for_period, ->(start_date, end_date) { where(snapshot_date: start_date..end_date) } + scope :recent, -> { order(snapshot_date: :desc) } +end diff --git a/enterprise/app/models/captain/unit_inbox.rb b/enterprise/app/models/captain/unit_inbox.rb index fc030477d..807d9f94c 100644 --- a/enterprise/app/models/captain/unit_inbox.rb +++ b/enterprise/app/models/captain/unit_inbox.rb @@ -1,3 +1,23 @@ +# == Schema Information +# +# Table name: captain_unit_inboxes +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# captain_unit_id :bigint not null +# inbox_id :bigint not null +# +# Indexes +# +# index_captain_unit_inboxes_on_inbox_id (inbox_id) +# index_captain_unit_inboxes_on_unit_and_inbox (captain_unit_id,inbox_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (captain_unit_id => captain_units.id) ON DELETE => cascade +# fk_rails_... (inbox_id => inboxes.id) ON DELETE => cascade +# class Captain::UnitInbox < ApplicationRecord self.table_name = 'captain_unit_inboxes' diff --git a/enterprise/app/services/captain/llm/conversation_insight_service.rb b/enterprise/app/services/captain/llm/conversation_insight_service.rb new file mode 100644 index 000000000..baede0360 --- /dev/null +++ b/enterprise/app/services/captain/llm/conversation_insight_service.rb @@ -0,0 +1,160 @@ +class Captain::Llm::ConversationInsightService < Llm::BaseAiService + include Integrations::LlmInstrumentation + + MAX_CHARS_PER_CHUNK = 40_000 + + def initialize(account:, unit:, conversations:) + super() + @account = account + @unit = unit + @conversations = conversations + end + + # Analisa as conversas e retorna o payload de insights + def analyze + chunks = build_chunks + return empty_payload if chunks.empty? + + results = chunks.filter_map { |chunk| analyze_chunk(chunk) } + return empty_payload if results.empty? + + merge_results(results) + end + + private + + attr_reader :account, :unit, :conversations + + def build_chunks + texts = conversations.map(&:to_llm_text).reject(&:blank?) + return [] if texts.empty? + + chunks = [] + current = [] + current_size = 0 + + texts.each do |text| + if current_size + text.length > MAX_CHARS_PER_CHUNK && current.any? + chunks << current.join("\n\n---\n\n") + current = [] + current_size = 0 + end + current << text + current_size += text.length + end + + chunks << current.join("\n\n---\n\n") if current.any? + chunks + end + + def analyze_chunk(chunk) + response = instrument_llm_call(instrumentation_params) do + chat + .with_params(response_format: { type: 'json_object' }) + .with_instructions(system_prompt) + .ask(chunk) + end + + parse_response(response.content) + rescue RubyLLM::Error => e + Rails.logger.error "[Captain::Llm::ConversationInsightService] LLM Error: #{e.message}" + nil + end + + def system_prompt + Captain::Llm::SystemPromptsService.conversation_insights_analyzer( + unit.name, + account.locale_english_name + ) + end + + def instrumentation_params + { + span_name: 'llm.captain.conversation_insights', + model: @model, + temperature: @temperature, + feature_name: 'conversation_insights', + account_id: account.id, + messages: [{ role: 'system', content: system_prompt }] + } + end + + def merge_results(results) + base = results.first.dup + + results.drop(1).each do |result| + merge_arrays!(base, result) + merge_sentiment!(base, result) + merge_highlights!(base, result) + base['recommendations'] = ((base['recommendations'] || []) + (result['recommendations'] || [])).uniq + end + + base + end + + def merge_arrays!(base, result) + base['top_topics'] = merge_by_topic(base['top_topics'], result['top_topics']) + base['ai_failures'] = merge_by_description(base['ai_failures'], result['ai_failures']) + base['faq_gaps'] = merge_by_question(base['faq_gaps'], result['faq_gaps']) + base['most_requested_suites'] = merge_by_suite(base['most_requested_suites'], result['most_requested_suites']) + end + + def merge_sentiment!(base, result) + %w[positive_count negative_count neutral_count].each do |key| + base['sentiment'][key] = base.dig('sentiment', key).to_i + result.dig('sentiment', key).to_i + end + end + + def merge_highlights!(base, result) + %w[praises complaints].each do |key| + base['highlights'][key] = (base.dig('highlights', key) || []) + (result.dig('highlights', key) || []) + end + end + + def merge_by_topic(arr_a, arr_b) + merge_arrays_by_key(arr_a, arr_b, 'topic', 'count') + end + + def merge_by_description(arr_a, arr_b) + merge_arrays_by_key(arr_a, arr_b, 'description', 'frequency') + end + + def merge_by_question(arr_a, arr_b) + merge_arrays_by_key(arr_a, arr_b, 'question', 'frequency') + end + + def merge_by_suite(arr_a, arr_b) + merge_arrays_by_key(arr_a, arr_b, 'suite', 'count') + end + + def merge_arrays_by_key(arr_a, arr_b, label_key, count_key) + merged = ((arr_a || []) + (arr_b || [])).group_by { |item| item[label_key] } + merged + .map { |_label, items| items.first.merge(count_key => items.sum { |i| i[count_key].to_i }) } + .sort_by { |item| -item[count_key].to_i } + .take(10) + end + + def parse_response(content) + return nil if content.nil? + + JSON.parse(content.strip) + rescue JSON::ParserError => e + Rails.logger.error "[Captain::Llm::ConversationInsightService] JSON parse error: #{e.message}" + nil + end + + def empty_payload + { + 'top_topics' => [], + 'ai_failures' => [], + 'faq_gaps' => [], + 'sentiment' => { 'positive_count' => 0, 'negative_count' => 0, 'neutral_count' => 0, 'summary' => '' }, + 'highlights' => { 'praises' => [], 'complaints' => [] }, + 'most_requested_suites' => [], + 'price_reactions' => { 'summary' => '', 'objections_count' => 0 }, + 'recommendations' => [], + 'period_summary' => 'Sem conversas suficientes para análise no período.' + } + end +end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index b48808ab7..7b8e78f87 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -205,6 +205,13 @@ class Captain::Llm::SystemPromptsService ``` - If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response #{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']} + + [Galeria de Fotos - Regras Importantes] + Quando o cliente pedir fotos de suítes: + - Se mencionar um NOME ou TIPO (ex: "suite Alexa", "suite hidromassagem", "suite STILO"), use o parâmetro suite_category + - Se mencionar um NÚMERO específico (ex: "suite 101", "quarto 202"), use o parâmetro suite_number + - Em caso de dúvida, priorize interpretar como suite_category, pois clientes geralmente se referem ao tipo da suíte + - NÃO peça confirmação ao cliente sobre categoria vs número - infira do contexto e chame a ferramenta diretamente SYSTEM_PROMPT_MESSAGE end @@ -289,6 +296,58 @@ class Captain::Llm::SystemPromptsService • Do NOT mention page numbers anywhere in questions or answers PROMPT end + + def conversation_insights_analyzer(unit_name, language = 'português') + <<~PROMPT + Você é um analista especializado em experiência de cliente para hotelaria. + Analise as conversas fornecidas entre clientes e o assistente de IA de uma unidade hoteleira chamada "#{unit_name}". + + Gere o relatório EXCLUSIVAMENTE em #{language} e APENAS com base nas conversas fornecidas. + NÃO invente dados, NÃO use conhecimento externo. + + Retorne SOMENTE um JSON válido com a estrutura abaixo, sem texto extra: + + ```json + { + "top_topics": [ + { "topic": "Nome do tópico", "count": 5, "description": "Breve descrição" } + ], + "ai_failures": [ + { "description": "Descrição do problema", "example": "Exemplo da conversa", "frequency": 3 } + ], + "faq_gaps": [ + { "question": "Pergunta sem cobertura no FAQ", "frequency": 2 } + ], + "sentiment": { + "positive_count": 10, + "negative_count": 3, + "neutral_count": 7, + "summary": "Resumo geral do sentimento dos clientes" + }, + "highlights": { + "praises": ["Elogio 1 capturado textualmente", "Elogio 2"], + "complaints": ["Reclamação 1", "Reclamação 2"] + }, + "most_requested_suites": [ + { "suite": "Nome ou categoria da suíte", "count": 4 } + ], + "price_reactions": { + "summary": "Como os clientes reagiram aos preços informados", + "objections_count": 2 + }, + "recommendations": [ + "Recomendação acionável baseada nos dados" + ], + "period_summary": "Resumo executivo de 2-3 linhas sobre a semana" + } + ``` + + Regras obrigatórias: + - Se não houver dados suficientes para algum campo, retorne arrays vazios ou strings vazias + - Nunca fabrique exemplos ou números + - O JSON deve ser válido e parseável + PROMPT + end # rubocop:enable Metrics/MethodLength end end diff --git a/enterprise/app/services/captain/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/tools/send_suite_images_tool.rb index 5b98ad31a..6083c1df1 100644 --- a/enterprise/app/services/captain/tools/send_suite_images_tool.rb +++ b/enterprise/app/services/captain/tools/send_suite_images_tool.rb @@ -18,11 +18,13 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool properties: { suite_category: { type: 'string', - description: 'Opcional. Categoria da suíte (ex: luxo, hidro, master).' + description: 'Opcional. Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO, VL, PrimeAL). ' \ + 'Use quando o cliente mencionar o TIPO/NOME da suíte, não o número.' }, suite_number: { type: 'string', - description: 'Opcional. Número/identificador da suíte (ex: 101, alexa, aluba).' + description: 'Opcional. Número específico da suíte (ex: 101, 102, 103, 109, 202). ' \ + 'Use APENAS quando o cliente mencionar um NÚMERO específico como "suíte 101" ou "quarto 202".' }, limit: { type: 'integer', @@ -211,12 +213,21 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool category = normalize_filter(actual_params[:suite_category]) suite_number = normalize_filter(actual_params[:suite_number]) + # Sugerir buscar por categoria se buscou por número e não achou + suggestion = if category.blank? && suite_number.present? + "\n\nDica para a IA: Tente buscar por suite_category em vez de suite_number." + else + '' + end + detail = [] detail << "categoria #{category}" if category.present? detail << "suíte #{suite_number}" if suite_number.present? detail_text = detail.present? ? " para #{detail.join(' e ')}" : '' - success_response("Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.") + success_response( + "Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.#{suggestion}" + ) end def success_payload(selected_items, sent_count, actual_params) From 87bff8126ce606e61a558dac6bf72607944b7c98 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Fri, 27 Feb 2026 07:05:58 -0300 Subject: [PATCH 2/6] feat(captain): add AI reports page with insights generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa a página Relatórios IA com geração de análises semanais por IA baseadas nas conversas de cada unidade/caixa de entrada. Funcionalidades: - Página /settings/captain/reports com dois tabs (Insights IA / Operacional) - Botão "Gerar Análise" que enfileira job Sidekiq - Filtro por unidade ou caixa de entrada - Exibe insights com status (pendente/processando/concluído/falhou) - Mostra top_topics, ai_failures e period_summary - Estado vazio com CTA para gerar primeiro relatório Backend: - InsightsController com endpoints index/show/generate - GenerateInsightsJob que processa conversas com LLM - ConversationInsightService com chunking e merge inteligente - Migração para adicionar inbox_id à tabela captain_conversation_insights - Link sidebar "Relatórios IA" em /settings/captain/reports Frontend: - Vuex store captainReports com actions/mutations/getters - API client CaptainReportsAPI (getInsights, generateInsight) - i18n en e pt_BR para CAPTAIN_REPORTS.* Co-Authored-By: Claude Sonnet 4.6 --- .../captain/reports/insights_controller.rb | 47 ++- .../dashboard/api/captain/reports.js | 26 ++ .../components-next/sidebar/Sidebar.vue | 6 + .../dashboard/i18n/locale/en/settings.json | 38 +++ .../dashboard/i18n/locale/pt_BR/settings.json | 41 +++ .../settings/captain/captain.routes.js | 9 + .../settings/captain/reports/Index.vue | 299 ++++++++++++++++++ .../dashboard/store/modules/captainReports.js | 64 ++-- ...box_id_to_captain_conversation_insights.rb | 26 ++ .../captain/reports/generate_insights_job.rb | 26 +- .../models/captain/conversation_insight.rb | 2 + .../llm/conversation_insight_service.rb | 8 +- .../captain/tools/send_suite_images_tool.rb | 85 +++-- 13 files changed, 577 insertions(+), 100 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/reports.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue create mode 100644 db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb diff --git a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb index 6dc0783a3..2eb0f6746 100644 --- a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb +++ b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb @@ -1,13 +1,19 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController + # rubocop:disable Metrics/AbcSize def index unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil - insights = Captain::ConversationInsight - .where(account_id: Current.account.id, captain_unit_id: unit_id) - .order(period_start: :desc) - .limit(12) + inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil + + scope = Captain::ConversationInsight.where(account_id: Current.account.id) + scope = scope.where(captain_unit_id: unit_id) if unit_id + scope = scope.where(inbox_id: inbox_id) if inbox_id + scope = scope.where(captain_unit_id: nil, inbox_id: nil) if !unit_id && !inbox_id + + insights = scope.order(period_start: :desc).limit(12) render json: insights.map { |i| format_insight(i) } end + # rubocop:enable Metrics/AbcSize def show insight = Captain::ConversationInsight.find_by!( @@ -19,35 +25,47 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account render json: { error: 'Insight não encontrado' }, status: :not_found end + # rubocop:disable Metrics/AbcSize def generate period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_week - 1.week) - period_end = parse_date(params[:period_end], Time.zone.today.beginning_of_week - 1.day) - unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil + period_end = parse_date(params[:period_end], Time.zone.today.beginning_of_week - 1.day) + unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil + inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil - enqueue_insight(unit_id, period_start, period_end) + enqueue_insight(unit_id, inbox_id, period_start, period_end) end + # rubocop:enable Metrics/AbcSize private - def enqueue_insight(unit_id, period_start, period_end) - insight = Captain::ConversationInsight.find_or_initialize_by( + 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? + + reset_and_enqueue!(insight, unit_id, inbox_id, period_start, period_end) + end + + def find_or_init_insight(unit_id, inbox_id, period_start, period_end) + Captain::ConversationInsight.find_or_initialize_by( account_id: Current.account.id, captain_unit_id: unit_id, + inbox_id: inbox_id, period_start: period_start, period_end: period_end ) + end - return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing? - + def reset_and_enqueue!(insight, unit_id, inbox_id, period_start, period_end) insight.status = 'pending' insight.payload = nil insight.save! - Captain::Reports::GenerateInsightsJob.perform_later( - Current.account.id, unit_id, period_start, period_end + Current.account.id, unit_id, period_start, period_end, inbox_id ) - render json: { status: 'queued', insight_id: insight.id }, status: :accepted + rescue StandardError => e + Rails.logger.error "Error generating insight: #{e.message}\n#{e.backtrace.join("\n")}" + render json: { error: "Erro ao enfileirar análise: #{e.message}" }, status: :internal_server_error end def parse_date(param, default) @@ -60,6 +78,7 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account { id: insight.id, unit_id: insight.captain_unit_id, + inbox_id: insight.inbox_id, period_start: insight.period_start, period_end: insight.period_end, status: insight.status, diff --git a/app/javascript/dashboard/api/captain/reports.js b/app/javascript/dashboard/api/captain/reports.js new file mode 100644 index 000000000..b1866f04c --- /dev/null +++ b/app/javascript/dashboard/api/captain/reports.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainReportsAPI extends ApiClient { + constructor() { + super('captain/reports', { accountScoped: true }); + } + + getOperational(params = {}) { + return axios.get(`${this.url}/operational`, { params }); + } + + getInsights(params = {}) { + return axios.get(`${this.url}/insights`, { params }); + } + + getInsight(id) { + return axios.get(`${this.url}/insights/${id}`); + } + + generateInsight(data) { + return axios.post(`${this.url}/insights/generate`, data); + } +} + +export default new CaptainReportsAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 6822182c6..cfc393b38 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -408,6 +408,12 @@ const menuItems = computed(() => { activeOn: ['captain_reservations_index'], to: accountScopedRoute('captain_reservations_index'), }, + { + name: 'Reports', + label: t('SIDEBAR.CAPTAIN_REPORTS'), + activeOn: ['captain_settings_reports'], + to: accountScopedRoute('captain_settings_reports'), + }, ], }, { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 8d417d797..e299342ba 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -1021,5 +1021,43 @@ "CONFIRM_BUTTON_LABEL": "Delete", "CANCEL_BUTTON_LABEL": "Cancel" } + }, + "CAPTAIN_REPORTS": { + "TITLE": "AI Reports", + "DESC": "Weekly AI-generated insights based on conversations from each unit.", + "LOADING": "Loading reports...", + "ALL_UNITS": "All units", + "TABS": { + "INSIGHTS": "AI Insights", + "OPERATIONAL": "Operational" + }, + "STATUS": { + "PENDING": "Pending", + "PROCESSING": "Processing", + "DONE": "Completed", + "FAILED": "Failed" + }, + "GENERATE": { + "BUTTON": "Generate Analysis", + "SUCCESS": "Analysis queued successfully! It will be available shortly.", + "ERROR": "Error requesting analysis. Please try again." + }, + "INSIGHT": { + "CONVERSATIONS": "conversations", + "MESSAGES": "messages", + "TOP_TOPICS": "Top Topics", + "AI_FAILURES": "AI Improvement Points", + "COUNT_PREFIX": "(", + "COUNT_SUFFIX": ")", + "BULLET": "•" + }, + "EMPTY": { + "TITLE": "No analysis available", + "MESSAGE": "Click Generate Analysis to create the first weekly report with conversation insights." + }, + "OPERATIONAL": { + "COMING_SOON": "Coming Soon", + "COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon." + } } } diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index 5b8d76209..e50562fd7 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -342,6 +342,7 @@ "CAPTAIN_PIX_UNITS": "Unidades Pix", "CAPTAIN_GALLERY": "Galeria", "CAPTAIN_RESERVATIONS": "Reservas", + "CAPTAIN_REPORTS": "Relatórios IA", "HOME": "Principal", "AGENTS": "Agentes", "AGENT_BOTS": "Robôs", @@ -817,5 +818,45 @@ "CONFIRM_BUTTON_LABEL": "Excluir", "CANCEL_BUTTON_LABEL": "Cancelar" } + }, + "CAPTAIN_REPORTS": { + "TITLE": "Relatórios IA", + "DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.", + "LOADING": "Carregando relatórios...", + "ALL_UNITS": "Todas as unidades", + "TABS": { + "INSIGHTS": "Insights IA", + "OPERATIONAL": "Operacional" + }, + "UNITS_GROUP": "Unidades Pix", + "INBOXES_GROUP": "Caixas de Entrada", + "STATUS": { + "PENDING": "Pendente", + "PROCESSING": "Processando", + "DONE": "Concluído", + "FAILED": "Falhou" + }, + "GENERATE": { + "BUTTON": "Gerar Análise", + "SUCCESS": "Análise enfileirada com sucesso! Em breve estará disponível.", + "ERROR": "Erro ao solicitar análise. Tente novamente." + }, + "INSIGHT": { + "CONVERSATIONS": "conversas", + "MESSAGES": "mensagens", + "TOP_TOPICS": "Principais Tópicos", + "AI_FAILURES": "Pontos de Melhoria da IA", + "COUNT_PREFIX": "(", + "COUNT_SUFFIX": ")", + "BULLET": "•" + }, + "EMPTY": { + "TITLE": "Nenhuma análise disponível", + "MESSAGE": "Clique em Gerar Análise para criar o primeiro relatório semanal com insights das conversas." + }, + "OPERATIONAL": { + "COMING_SOON": "Em breve", + "COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve." + } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js index dd169f5a6..c99b08b3c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js @@ -7,6 +7,7 @@ import UnitsIndex from './units/Index.vue'; import UnitEdit from './units/Edit.vue'; import GalleryIndex from './gallery/Index.vue'; import GalleryEdit from './gallery/Edit.vue'; +const ReportsIndex = () => import('./reports/Index.vue'); export default { routes: [ @@ -68,6 +69,14 @@ export default { permissions: ['administrator'], }, }, + { + path: 'reports', + name: 'captain_settings_reports', + component: ReportsIndex, + meta: { + permissions: ['administrator'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue new file mode 100644 index 000000000..29428220a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -0,0 +1,299 @@ + + + diff --git a/app/javascript/dashboard/store/modules/captainReports.js b/app/javascript/dashboard/store/modules/captainReports.js index e82dbce81..338c85b2b 100644 --- a/app/javascript/dashboard/store/modules/captainReports.js +++ b/app/javascript/dashboard/store/modules/captainReports.js @@ -1,36 +1,5 @@ import * as MutationTypes from '../mutation-types'; -import ApiClient from '../../api'; - -const captainReportsAPI = { - getOperational: (accountId, params) => - ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/operational`, { - params, - }), - getInsights: (accountId, params) => - ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/insights`, { - params, - }), - getInsight: (accountId, id) => - ApiClient.get( - `/api/v1/accounts/${accountId}/captain/reports/insights/${id}` - ), - generateInsight: (accountId, data) => - ApiClient.post( - `/api/v1/accounts/${accountId}/captain/reports/insights/generate`, - data - ), -}; - -const state = { - operational: null, - insights: [], - currentInsight: null, - uiFlags: { - isFetchingOperational: false, - isFetchingInsights: false, - isGenerating: false, - }, -}; +import CaptainReportsAPI from '../../api/captain/reports'; export const getters = { getOperational: $state => $state.operational, @@ -55,16 +24,12 @@ export const mutations = { }; export const actions = { - async fetchOperational({ commit, rootGetters }, params = {}) { - const accountId = rootGetters['auth/getCurrentAccountId']; + async fetchOperational({ commit }, params = {}) { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isFetchingOperational: true, }); try { - const { data } = await captainReportsAPI.getOperational( - accountId, - params - ); + const { data } = await CaptainReportsAPI.getOperational(params); commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data); } finally { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { @@ -73,13 +38,12 @@ export const actions = { } }, - async fetchInsights({ commit, rootGetters }, params = {}) { - const accountId = rootGetters['auth/getCurrentAccountId']; + async fetchInsights({ commit }, params = {}) { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isFetchingInsights: true, }); try { - const { data } = await captainReportsAPI.getInsights(accountId, params); + const { data } = await CaptainReportsAPI.getInsights(params); commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data); } finally { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { @@ -88,12 +52,11 @@ export const actions = { } }, - async generateInsight({ commit, dispatch, rootGetters }, payload) { - const accountId = rootGetters['auth/getCurrentAccountId']; + async generateInsight({ commit, dispatch }, params) { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true }); try { - await captainReportsAPI.generateInsight(accountId, payload); - await dispatch('fetchInsights', { unit_id: payload.unit_id }); + await CaptainReportsAPI.generateInsight(params); + await dispatch('fetchInsights', params); } finally { commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: false, @@ -102,6 +65,17 @@ export const actions = { }, }; +const state = { + operational: null, + insights: [], + currentInsight: null, + uiFlags: { + isFetchingOperational: false, + isFetchingInsights: false, + isGenerating: false, + }, +}; + export default { namespaced: true, state, diff --git a/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb b/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb new file mode 100644 index 000000000..226043768 --- /dev/null +++ b/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb @@ -0,0 +1,26 @@ +class AddInboxIdToCaptainConversationInsights < ActiveRecord::Migration[7.1] + def up + add_reference :captain_conversation_insights, :inbox, foreign_key: true, null: true + + # Remove índice antigo que causaria conflito com o novo índice composto + remove_index :captain_conversation_insights, name: 'idx_captain_insights_unique_period' + + # Novo índice que permite análise por Unidade OU por Inbox + add_index :captain_conversation_insights, + %i[captain_unit_id inbox_id period_start period_end], + unique: true, + name: 'idx_captain_insights_on_unit_inbox_period' + end + + def down + remove_index :captain_conversation_insights, name: 'idx_captain_insights_on_unit_inbox_period' + + # Recria o índice original para tornar o rollback completo + add_index :captain_conversation_insights, + %i[captain_unit_id period_start period_end], + unique: true, + name: 'idx_captain_insights_unique_period' + + remove_reference :captain_conversation_insights, :inbox, foreign_key: true + end +end diff --git a/enterprise/app/jobs/captain/reports/generate_insights_job.rb b/enterprise/app/jobs/captain/reports/generate_insights_job.rb index ecb7390b2..f0f3eda2c 100644 --- a/enterprise/app/jobs/captain/reports/generate_insights_job.rb +++ b/enterprise/app/jobs/captain/reports/generate_insights_job.rb @@ -1,18 +1,20 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob queue_as :default - # Gera insights de IA para uma unidade específica em um período. + # Gera insights de IA para uma unidade ou inbox específica em um período. # Pode ser disparado on-demand (botão na UI) ou pelo WeeklyInsightsJob. - def perform(account_id, unit_id, period_start, period_end) + def perform(account_id, unit_id, period_start, period_end, inbox_id = nil) account = Account.find_by(id: account_id) return unless account - unit = account.captain_units.find_by(id: unit_id) - insight = find_or_create_insight(account_id, unit_id, period_start, period_end) + unit = account.captain_units.find_by(id: unit_id) if unit_id + inbox = account.inboxes.find_by(id: inbox_id) if inbox_id + + insight = find_or_create_insight(account_id, unit_id, inbox_id, period_start, period_end) return if insight.processing? || insight.done? insight.mark_processing! - run_analysis(account, unit, insight, period_start, period_end) + run_analysis(account, unit, inbox, insight, period_start, period_end) rescue StandardError => e Rails.logger.error "[Captain::Reports::GenerateInsightsJob] Error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" insight&.mark_failed! @@ -20,10 +22,11 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob private - def find_or_create_insight(account_id, unit_id, period_start, period_end) + def find_or_create_insight(account_id, unit_id, inbox_id, period_start, period_end) insight = Captain::ConversationInsight.find_or_initialize_by( account_id: account_id, captain_unit_id: unit_id, + inbox_id: inbox_id, period_start: period_start, period_end: period_end ) @@ -31,13 +34,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob insight end - def run_analysis(account, unit, insight, period_start, period_end) - conversations = fetch_conversations(account, unit, period_start, period_end) + def run_analysis(account, unit, inbox, insight, period_start, period_end) + conversations = fetch_conversations(account, unit, inbox, period_start, period_end) insight.update!(conversations_count: conversations.count) payload = Captain::Llm::ConversationInsightService.new( account: account, unit: unit, + inbox: inbox, conversations: conversations ).analyze @@ -45,12 +49,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob insight.mark_done!(payload) end - def fetch_conversations(account, unit, period_start, period_end) + def fetch_conversations(account, unit, inbox, period_start, period_end) scope = account.conversations .where(created_at: period_start.beginning_of_day..period_end.end_of_day) .includes(:messages) - if unit + if inbox + scope = scope.where(inbox_id: inbox.id) + elsif unit inbox_ids = unit.inboxes.pluck(:id) scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? end diff --git a/enterprise/app/models/captain/conversation_insight.rb b/enterprise/app/models/captain/conversation_insight.rb index 1f34f8f71..6b489a364 100644 --- a/enterprise/app/models/captain/conversation_insight.rb +++ b/enterprise/app/models/captain/conversation_insight.rb @@ -24,12 +24,14 @@ class Captain::ConversationInsight < ApplicationRecord belongs_to :account belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true + belongs_to :inbox, optional: true validates :period_start, :period_end, :status, presence: true validates :status, inclusion: { in: STATUSES } scope :done, -> { where(status: 'done') } scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) } + scope :for_inbox, ->(inbox_id) { where(inbox_id: inbox_id) } scope :for_period, ->(start_date, end_date) { where(period_start: start_date, period_end: end_date) } def mark_processing! diff --git a/enterprise/app/services/captain/llm/conversation_insight_service.rb b/enterprise/app/services/captain/llm/conversation_insight_service.rb index baede0360..c6ad64a5b 100644 --- a/enterprise/app/services/captain/llm/conversation_insight_service.rb +++ b/enterprise/app/services/captain/llm/conversation_insight_service.rb @@ -3,10 +3,11 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService MAX_CHARS_PER_CHUNK = 40_000 - def initialize(account:, unit:, conversations:) + def initialize(account:, conversations:, unit: nil, inbox: nil) super() @account = account @unit = unit + @inbox = inbox @conversations = conversations end @@ -23,7 +24,7 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService private - attr_reader :account, :unit, :conversations + attr_reader :account, :unit, :inbox, :conversations def build_chunks texts = conversations.map(&:to_llm_text).reject(&:blank?) @@ -62,8 +63,9 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService end def system_prompt + entity_name = inbox&.name || unit&.name || 'Geral' Captain::Llm::SystemPromptsService.conversation_insights_analyzer( - unit.name, + entity_name, account.locale_english_name ) end diff --git a/enterprise/app/services/captain/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/tools/send_suite_images_tool.rb index 6083c1df1..6bf282481 100644 --- a/enterprise/app/services/captain/tools/send_suite_images_tool.rb +++ b/enterprise/app/services/captain/tools/send_suite_images_tool.rb @@ -18,17 +18,19 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool properties: { suite_category: { type: 'string', - description: 'Opcional. Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO, VL, PrimeAL). ' \ - 'Use quando o cliente mencionar o TIPO/NOME da suíte, não o número.' + description: 'Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO). ' \ + 'Use SOMENTE quando o cliente mencionar o TIPO/NOME da suíte sem citar um número específico. ' \ + 'Não combine com suite_number — os parâmetros são mutuamente exclusivos.' }, suite_number: { type: 'string', - description: 'Opcional. Número específico da suíte (ex: 101, 102, 103, 109, 202). ' \ - 'Use APENAS quando o cliente mencionar um NÚMERO específico como "suíte 101" ou "quarto 202".' + description: 'Número específico da suíte (ex: 101, 202, 109). ' \ + 'Use quando o cliente mencionar um NÚMERO como "suíte 101". ' \ + 'Quando fornecido, IGNORA suite_category. Não combine com suite_category.' }, limit: { type: 'integer', - description: 'Opcional. Quantidade de imagens para enviar (padrão: 3, máximo: 5).' + description: 'Quantidade de imagens para enviar (padrão: 3, máximo: 5).' }, inbox_id: { type: 'integer', @@ -150,27 +152,45 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool hash.key?('conversation') end + # rubocop:disable Metrics/MethodLength + # Lógica de busca mutuamente exclusiva: + # - Suite number fornecido → busca SOMENTE por número (ignora categoria) + # - Só categoria fornecida → busca SOMENTE por categoria def find_items(actual_params) - scope = Captain::GalleryItem - .active - .where(account_id: @conversation.account_id) - .includes(image_attachment: :blob) - .ordered + suite_number = normalize_text_search(actual_params[:suite_number]) + category = normalize_text_search(actual_params[:suite_category]) - unit_id = actual_params[:captain_unit_id].presence - scope = scope.where(captain_unit_id: unit_id) if unit_id.present? + base_scope = Captain::GalleryItem + .active + .where(account_id: @conversation.account_id) + .includes(image_attachment: :blob) + .ordered - category = normalize_filter(actual_params[:suite_category]) - suite_number = normalize_filter(actual_params[:suite_number]) + if suite_number.present? + # Prioridade: número da suíte (match exato normalizado) + filters = base_scope.where('LOWER(suite_number) = ?', suite_number) + elsif category.present? + # Categoria: fuzzy case-insensitive, ignora acentos via REPLACE + filters = base_scope.where( + 'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \ + "'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \ + '= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \ + "'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))", + category + ) + else + return Captain::GalleryItem.none + end - scope = scope.where('LOWER(suite_category) = ?', category.downcase) if category.present? - scope = scope.where('LOWER(suite_number) = ?', suite_number.downcase) if suite_number.present? + # Tenta primeiro o inbox atual da conversa (jamais busca em outros inboxes) + target_inbox = resolve_target_inbox_id(actual_params) + inbox_result = filters.where(scope: 'inbox', inbox_id: target_inbox) + return inbox_result if inbox_result.exists? - inbox_scope = scope.where(scope: 'inbox', inbox_id: resolve_target_inbox_id(actual_params)) - return inbox_scope if inbox_scope.exists? - - scope.where(scope: 'global') + # Fallback APENAS para acervo global (fotos genéricas sem vínculo de unidade) + filters.where(scope: 'global') end + # rubocop:enable Metrics/MethodLength def find_selected_items(actual_params) items = find_items(actual_params) @@ -202,6 +222,12 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool value.to_s.strip.presence end + # Normaliza para comparação SQL: strip + downcase + def normalize_text_search(value) + str = value.to_s.strip.downcase + str.presence + end + def resolve_target_inbox_id(actual_params) requested_inbox_id = actual_params[:inbox_id].presence return @conversation.inbox_id if requested_inbox_id.blank? @@ -210,23 +236,26 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool end def no_images_response(actual_params) - category = normalize_filter(actual_params[:suite_category]) + category = normalize_filter(actual_params[:suite_category]) suite_number = normalize_filter(actual_params[:suite_number]) - # Sugerir buscar por categoria se buscou por número e não achou + # Se buscou por número e não achou, sugerir tentar pela categoria da suíte suggestion = if category.blank? && suite_number.present? - "\n\nDica para a IA: Tente buscar por suite_category em vez de suite_number." + ' Dica: tente usar suite_category para buscar fotos da categoria desta suíte.' else '' end - detail = [] - detail << "categoria #{category}" if category.present? - detail << "suíte #{suite_number}" if suite_number.present? - detail_text = detail.present? ? " para #{detail.join(' e ')}" : '' + searched_for = if suite_number.present? + "suíte #{suite_number}" + elsif category.present? + "categoria #{category}" + else + 'as fotos solicitadas' + end success_response( - "Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.#{suggestion}" + "Não encontrei fotos para #{searched_for} na galeria (nem por inbox nem no acervo global).#{suggestion}" ) end From a67d164e8fd411d322bce70f1a4f2252eedaf2b9 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Fri, 27 Feb 2026 07:31:32 -0300 Subject: [PATCH 3/6] fix(captain-reports): remove units filter, keep inbox-only filter Replace unit+inbox combined dropdown with inbox-only select. Add ALL_INBOXES i18n key in pt_BR and en. Units (Pix) are unrelated to conversation reports. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/i18n/locale/en/settings.json | 3 +- .../dashboard/i18n/locale/pt_BR/settings.json | 1 + .../settings/captain/reports/Index.vue | 67 ++++++------------- 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index e299342ba..559399f08 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -1024,9 +1024,10 @@ }, "CAPTAIN_REPORTS": { "TITLE": "AI Reports", - "DESC": "Weekly AI-generated insights based on conversations from each unit.", + "DESC": "Weekly AI-generated insights based on conversations from each inbox.", "LOADING": "Loading reports...", "ALL_UNITS": "All units", + "ALL_INBOXES": "All inboxes", "TABS": { "INSIGHTS": "AI Insights", "OPERATIONAL": "Operational" diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index e50562fd7..95fea843a 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -824,6 +824,7 @@ "DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.", "LOADING": "Carregando relatórios...", "ALL_UNITS": "Todas as unidades", + "ALL_INBOXES": "Todas as caixas de entrada", "TABS": { "INSIGHTS": "Insights IA", "OPERATIONAL": "Operacional" diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue index 29428220a..1ade106f3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -10,53 +10,33 @@ import Button from 'dashboard/components-next/button/Button.vue'; const { t } = useI18n(); const store = useStore(); -const units = useMapGetter('captainUnits/getUnits'); const inboxes = useMapGetter('inboxes/getInboxes'); const insights = useMapGetter('captainReports/getInsights'); const uiFlags = useMapGetter('captainReports/getUIFlags'); const activeTab = ref('insights'); -const selectedFilter = ref({ type: 'unit', id: null }); +const selectedInboxId = ref(null); const tabs = [{ key: 'insights' }, { key: 'operational' }]; onMounted(async () => { - await store.dispatch('captainUnits/get'); await store.dispatch('inboxes/get'); - await store.dispatch('captainReports/fetchInsights', { - unit_id: null, - }); + await store.dispatch('captainReports/fetchInsights', {}); }); const onFilterChange = async event => { const value = event.target.value; - if (!value) { - selectedFilter.value = { type: 'unit', id: null }; - } else { - const [type, id] = value.split(':'); - selectedFilter.value = { type, id: Number(id) }; - } - - const params = {}; - if (selectedFilter.value.type === 'unit') { - params.unit_id = selectedFilter.value.id; - } else { - params.inbox_id = selectedFilter.value.id; - } - - await store.dispatch('captainReports/fetchInsights', params); + selectedInboxId.value = value ? Number(value) : null; + await store.dispatch('captainReports/fetchInsights', { + inbox_id: selectedInboxId.value, + }); }; const onGenerateInsight = async () => { - const params = {}; - if (selectedFilter.value.type === 'unit') { - params.unit_id = selectedFilter.value.id; - } else { - params.inbox_id = selectedFilter.value.id; - } - try { - await store.dispatch('captainReports/generateInsight', params); + await store.dispatch('captainReports/generateInsight', { + inbox_id: selectedInboxId.value, + }); useAlert(t('CAPTAIN_REPORTS.GENERATE.SUCCESS')); } catch { useAlert(t('CAPTAIN_REPORTS.GENERATE.ERROR')); @@ -108,25 +88,16 @@ const periodLabel = insight => class="rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2 text-sm text-n-slate-12 focus:outline-none focus:ring-2 focus:ring-n-brand" @change="onFilterChange" > - - - - - - - + +