diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..abc68b26b --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,101 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + resolved_counts: fetch_resolved_counts(conversation_filter), + resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), + first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), + reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + fetch_counts(conversation_filter.merge(status: :resolved)) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + end +end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a2cb466f1..6a4ce2461 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,7 +15,7 @@ class DashboardController < ActionController::Base private def ensure_html_format - head :not_acceptable unless request.format.html? + render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? end def set_global_config diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 75e7e2953..a1b15ee79 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -38,13 +38,7 @@ export default { } return false; }, - profileUpdate({ - password, - password_confirmation, - displayName, - avatar, - ...profileAttributes - }) { + profileUpdate({ displayName, avatar, ...profileAttributes }) { const formData = new FormData(); Object.keys(profileAttributes).forEach(key => { const hasValue = profileAttributes[key] === undefined; @@ -53,16 +47,22 @@ export default { } }); formData.append('profile[display_name]', displayName || ''); - if (password && password_confirmation) { - formData.append('profile[password]', password); - formData.append('profile[password_confirmation]', password_confirmation); - } if (avatar) { formData.append('profile[avatar]', avatar); } return axios.put(endPoints('profileUpdate').url, formData); }, + profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { + current_password: currentPassword, + password, + password_confirmation: passwordConfirmation, + }, + }); + }, + updateUISettings({ uiSettings }) { return axios.put(endPoints('profileUpdate').url, { profile: { ui_settings: uiSettings }, diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 5409aac60..ecd3f0170 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -51,6 +51,7 @@ const endPoints = { resendConfirmation: { url: '/api/v1/profile/resend_confirmation', }, + resetAccessToken: { url: '/api/v1/profile/reset_access_token', }, diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js index f772ef86f..fad26bf6f 100644 --- a/app/javascript/dashboard/api/summaryReports.js +++ b/app/javascript/dashboard/api/summaryReports.js @@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient { }, }); } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } } export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Form.vue b/app/javascript/dashboard/components-next/message/bubbles/Form.vue index ca9af4994..12cfafa8f 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Form.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Form.vue @@ -4,9 +4,11 @@ import BaseBubble from './Base.vue'; import { useI18n } from 'vue-i18n'; import { CONTENT_TYPES } from '../constants.js'; import { useMessageContext } from '../provider.js'; +import { useInbox } from 'dashboard/composables/useInbox'; const { content, contentAttributes, contentType } = useMessageContext(); const { t } = useI18n(); +const { isAWebWidgetInbox } = useInbox(); const formValues = computed(() => { if (contentType.value === CONTENT_TYPES.FORM) { @@ -56,7 +58,7 @@ const formValues = computed(() => {
{{ item.title }}
-
+
{{ t('CONVERSATION.NO_RESPONSE') }}
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 5d7aac3c3..171f4a4d8 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -87,7 +87,7 @@ const newReportRoutes = () => [ { name: 'Reports Label', label: t('SIDEBAR.REPORTS_LABEL'), - to: accountScopedRoute('label_reports'), + to: accountScopedRoute('label_reports_index'), }, { name: 'Reports Inbox', diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 098bc41c1..de895c76e 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -853,6 +853,8 @@ watch(conversationFilters, (newVal, oldVal) => { :has-active-folders="hasActiveFolders" :active-status="activeStatus" :is-on-expanded-layout="isOnExpandedLayout" + :conversation-stats="conversationStats" + :is-list-loading="chatListLoading" @add-folders="onClickOpenAddFoldersModal" @delete-folders="onClickOpenDeleteFoldersModal" @filters-modal="onToggleAdvanceFiltersModal" diff --git a/app/javascript/dashboard/components/ChatListHeader.vue b/app/javascript/dashboard/components/ChatListHeader.vue index b4ce9f342..c184103c0 100644 --- a/app/javascript/dashboard/components/ChatListHeader.vue +++ b/app/javascript/dashboard/components/ChatListHeader.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useMapGetter } from 'dashboard/composables/store.js'; +import { formatNumber } from '@chatwoot/utils'; import wootConstants from 'dashboard/constants/globals'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; @@ -10,26 +11,13 @@ import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchL import NextButton from 'dashboard/components-next/button/Button.vue'; const props = defineProps({ - pageTitle: { - type: String, - required: true, - }, - hasAppliedFilters: { - type: Boolean, - required: true, - }, - hasActiveFolders: { - type: Boolean, - required: true, - }, - activeStatus: { - type: String, - required: true, - }, - isOnExpandedLayout: { - type: Boolean, - required: true, - }, + pageTitle: { type: String, required: true }, + hasAppliedFilters: { type: Boolean, required: true }, + hasActiveFolders: { type: Boolean, required: true }, + activeStatus: { type: String, required: true }, + isOnExpandedLayout: { type: Boolean, required: true }, + conversationStats: { type: Object, required: true }, + isListLoading: { type: Boolean, required: true }, }); const emit = defineEmits([ @@ -62,6 +50,9 @@ const showV4View = computed(() => { ); }); +const allCount = computed(() => props.conversationStats?.allCount || 0); +const formattedAllCount = computed(() => formatNumber(allCount.value)); + const toggleConversationLayout = () => { const { LAYOUT_TYPES } = wootConstants; const { @@ -92,6 +83,15 @@ const toggleConversationLayout = () => { > {{ pageTitle }} + + {{ formattedAllCount }} + store.getters.getSelectedChat); const accountId = computed(() => store.getters.getCurrentAccountId); -const isFeatureEnabledonAccount = computed( - () => store.getters['accounts/isFeatureEnabledonAccount'] -); -const appIntegrations = computed( - () => store.getters['integrations/getAppIntegrations'] -); const chatMetadata = computed(() => props.chat.meta); @@ -92,16 +84,6 @@ const hasMultipleInboxes = computed( ); const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); - -const isLinearIntegrationEnabled = computed(() => - appIntegrations.value.find( - integration => integration.id === 'linear' && !!integration.hooks.length - ) -); - -const isLinearFeatureEnabled = computed(() => - isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR) -);