diff --git a/Gemfile b/Gemfile index ffde3d3e3..bbc7a6f74 100644 --- a/Gemfile +++ b/Gemfile @@ -108,9 +108,9 @@ gem 'elastic-apm', require: false gem 'newrelic_rpm', require: false gem 'newrelic-sidekiq-metrics', require: false gem 'scout_apm', require: false -gem 'sentry-rails', require: false +gem 'sentry-rails', '>= 5.10.0', require: false gem 'sentry-ruby', require: false -gem 'sentry-sidekiq', require: false +gem 'sentry-sidekiq', '>= 5.10.0', require: false ##-- background job processing --## gem 'sidekiq' @@ -213,6 +213,7 @@ group :development, :test do gem 'bundle-audit', require: false gem 'byebug', platform: :mri gem 'climate_control' + gem 'debug', '~> 1.8' gem 'factory_bot_rails' gem 'listen' gem 'mock_redis' diff --git a/Gemfile.lock b/Gemfile.lock index 6b52c1e67..0b250bd77 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -164,7 +164,7 @@ GEM coderay (1.1.3) commonmarker (0.23.9) concurrent-ruby (1.2.2) - connection_pool (2.4.0) + connection_pool (2.4.1) crack (0.4.5) rexml crass (1.0.6) @@ -184,6 +184,9 @@ GEM libddwaf (~> 1.8.2.0.0) msgpack debase-ruby_core_source (3.2.0) + debug (1.8.0) + irb (>= 1.5.0) + reline (>= 0.3.1) declarative (0.0.20) devise (4.9.2) bcrypt (~> 3.0) @@ -366,6 +369,9 @@ GEM blingfire (>= 0.1.7) numo-narray onnxruntime (>= 0.5.1) + io-console (0.6.0) + irb (1.7.2) + reline (>= 0.3.6) jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -470,14 +476,14 @@ GEM sidekiq newrelic_rpm (8.16.0) nio4r (2.5.9) - nokogiri (1.15.2) + nokogiri (1.15.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.15.2-arm64-darwin) + nokogiri (1.15.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.15.2-x86_64-darwin) + nokogiri (1.15.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.15.2-x86_64-linux) + nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) numo-narray (0.9.2.1) oauth (1.1.0) @@ -568,8 +574,9 @@ GEM activesupport (= 7.0.5.1) bundler (>= 1.15.0) railties (= 7.0.5.1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.1.1) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) rails-html-sanitizer (1.6.0) loofah (~> 2.21) @@ -593,6 +600,8 @@ GEM redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.8.0) + reline (0.3.6) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -683,18 +692,18 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.9.0) + sentry-rails (5.10.0) railties (>= 5.0) - sentry-ruby (~> 5.9.0) - sentry-ruby (5.9.0) + sentry-ruby (~> 5.10.0) + sentry-ruby (5.10.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.9.0) - sentry-ruby (~> 5.9.0) + sentry-sidekiq (5.10.0) + sentry-ruby (~> 5.10.0) sidekiq (>= 3.0) sexp_processor (4.17.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.1.0) + sidekiq (7.1.2) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) @@ -835,6 +844,7 @@ DEPENDENCIES cypress-on-rails database_cleaner ddtrace + debug (~> 1.8) devise devise-secure_password! devise_token_auth @@ -904,9 +914,9 @@ DEPENDENCIES scout_apm scss_lint seed_dump - sentry-rails + sentry-rails (>= 5.10.0) sentry-ruby - sentry-sidekiq + sentry-sidekiq (>= 5.10.0) shoulda-matchers sidekiq sidekiq-cron diff --git a/Makefile b/Makefile index 499d0ab6b..16eb80718 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,23 @@ burn: bundle && yarn run: + @if [ -f ./.overmind.sock ]; then \ + echo "Overmind is already running. Use 'make force_run' to start a new instance."; \ + else \ + overmind start -f Procfile.dev; \ + fi + +force_run: + rm -f ./.overmind.sock overmind start -f Procfile.dev +debug: + overmind connect backend + +debug_worker: + overmind connect worker + docker: docker build -t $(APP_NAME) -f ./docker/Dockerfile . -.PHONY: setup db_create db_migrate db_seed db console server burn docker run +.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index b5fb8045b..b0f6ba25e 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -33,18 +33,18 @@ class V2::ReportBuilder def summary { - conversations_count: conversations_count.values.sum, - incoming_messages_count: incoming_messages_count.values.sum, - outgoing_messages_count: outgoing_messages_count.values.sum, + conversations_count: conversations.count, + incoming_messages_count: incoming_messages.count, + outgoing_messages_count: outgoing_messages.count, avg_first_response_time: avg_first_response_time_summary, avg_resolution_time: avg_resolution_time_summary, - resolutions_count: resolutions_count.values.sum + resolutions_count: resolutions.count } end def conversation_metrics if params[:type].equal?(:account) - conversations + live_conversations else agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse end @@ -89,12 +89,12 @@ class V2::ReportBuilder email: @user.email, thumbnail: @user.avatar_url, availability: account_user.availability_status, - metric: conversations + metric: live_conversations } end end - def conversations + def live_conversations @open_conversations = scope.conversations.where(account_id: @account.id).open metric = { open: @open_conversations.count, diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 8ee747eb9..796986e5b 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -17,21 +17,36 @@ module ReportHelper end def conversations_count - (get_grouped_values scope.conversations.where(account_id: account.id)).count + (get_grouped_values conversations).count end def incoming_messages_count - (get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count + (get_grouped_values incoming_messages).count end def outgoing_messages_count - (get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count + (get_grouped_values outgoing_messages).count end def resolutions_count - object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, - conversations: { status: :resolved }).distinct - (get_grouped_values object_scope).count + (get_grouped_values resolutions).count + end + + def conversations + scope.conversations.where(account_id: account.id, created_at: range) + end + + def incoming_messages + scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) + end + + def outgoing_messages + scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) + end + + def resolutions + scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, + conversations: { status: :resolved }, created_at: range).distinct end def avg_first_response_time @@ -51,7 +66,11 @@ module ReportHelper def avg_resolution_time_summary reporting_events = scope.reporting_events .where(name: 'conversation_resolved', account_id: account.id, created_at: range) - avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + avg_rt = if params[:business_hours].present? + reporting_events.average(:value_in_business_hours) + else + reporting_events.average(:value) + end return 0 if avg_rt.blank? @@ -61,7 +80,11 @@ module ReportHelper def avg_first_response_time_summary reporting_events = scope.reporting_events .where(name: 'first_response', account_id: account.id, created_at: range) - avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + avg_frt = if params[:business_hours].present? + reporting_events.average(:value_in_business_hours) + else + reporting_events.average(:value) + end return 0 if avg_frt.blank? diff --git a/app/javascript/dashboard/assets/scss/widgets/_report.scss b/app/javascript/dashboard/assets/scss/widgets/_report.scss index e9ce5d0bd..d07f1dd3d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_report.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_report.scss @@ -58,31 +58,3 @@ text-transform: capitalize; } } - -.report-bar { - @include background-white; - @include border-light; - margin: var(--space-minus-micro) 0; - padding: var(--space-small) var(--space-medium); - - .chart-container { - @include flex; - @include flex-align(center, middle); - flex-direction: column; - - div { - width: 100%; - } - - .empty-state { - color: $color-gray; - font-size: var(--font-size-default); - margin: var(--space-jumbo); - } - - .business-hours { - margin: var(--space-normal); - text-align: center; - } - } -} diff --git a/app/javascript/dashboard/assets/scss/widgets/_reports.scss b/app/javascript/dashboard/assets/scss/widgets/_reports.scss index c848bc0c8..dcd65366a 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_reports.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_reports.scss @@ -22,18 +22,6 @@ margin: 0 var(--space-small); } -.business-hours { - align-items: center; - display: flex; - justify-content: flex-start; - margin-left: auto; - padding-right: var(--space-normal); -} - -.business-hours-text { - font-size: var(--font-size-small); - margin: 0 var(--space-small); -} .switch { margin-bottom: var(--space-zero); diff --git a/app/javascript/dashboard/components/widgets/chart/BarChart.js b/app/javascript/dashboard/components/widgets/chart/BarChart.js index a4dca263b..22ee0f837 100644 --- a/app/javascript/dashboard/components/widgets/chart/BarChart.js +++ b/app/javascript/dashboard/components/widgets/chart/BarChart.js @@ -1,16 +1,20 @@ import { Bar } from 'vue-chartjs'; const fontFamily = - '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; + 'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; const defaultChartOptions = { responsive: true, maintainAspectRatio: false, legend: { + display: false, labels: { fontFamily, }, }, + animation: { + duration: 0, + }, datasets: { bar: { barPercentage: 1.0, @@ -46,11 +50,11 @@ export default { props: { collection: { type: Object, - default: () => {}, + default: () => ({}), }, chartOptions: { type: Object, - default: () => {}, + default: () => ({}), }, }, mounted() { diff --git a/app/javascript/dashboard/helper/auditlogHelper.js b/app/javascript/dashboard/helper/auditlogHelper.js index 7f912fb95..467b416f9 100644 --- a/app/javascript/dashboard/helper/auditlogHelper.js +++ b/app/javascript/dashboard/helper/auditlogHelper.js @@ -30,6 +30,7 @@ const translationKeys = { 'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`, 'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`, 'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`, + 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, }; function extractAttrChange(attrChange) { diff --git a/app/javascript/dashboard/i18n/locale/en/auditLogs.json b/app/javascript/dashboard/i18n/locale/en/auditLogs.json index a1c64b51e..f3e8edc2c 100644 --- a/app/javascript/dashboard/i18n/locale/en/auditLogs.json +++ b/app/javascript/dashboard/i18n/locale/en/auditLogs.json @@ -55,6 +55,9 @@ "ADD": "%{agentName} created a new macro (#%{id})", "EDIT": "%{agentName} updated a macro (#%{id})", "DELETE": "%{agentName} deleted a macro (#%{id})" + }, + "ACCOUNT": { + "EDIT": "%{agentName} updated the account configuration (#%{id})" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 5858605a0..f384adad7 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -12,11 +12,11 @@ "DESC": "( Total )" }, "INCOMING_MESSAGES": { - "NAME": "Incoming Messages", + "NAME": "Messages received", "DESC": "( Total )" }, "OUTGOING_MESSAGES": { - "NAME": "Outgoing Messages", + "NAME": "Messages sent", "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { @@ -93,7 +93,6 @@ { "id": 3, "groupBy": "Month" } ], "GROUP_BY_YEAR_OPTIONS": [ - { "id": 1, "groupBy": "Day" }, { "id": 2, "groupBy": "Week" }, { "id": 3, "groupBy": "Month" }, { "id": 4, "groupBy": "Year" } diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index d5bb8c9cc..3a53f1196 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -10,42 +10,36 @@ export default { calculateTrend() { return metric_key => { if (!this.accountSummary.previous[metric_key]) return 0; + const diff = + this.accountSummary[metric_key] - + this.accountSummary.previous[metric_key]; return Math.round( - ((this.accountSummary[metric_key] - - this.accountSummary.previous[metric_key]) / - this.accountSummary.previous[metric_key]) * - 100 - ); - }; - }, - displayMetric() { - return metric_key => { - if (this.isAverageMetricType(metric_key)) { - return formatTime(this.accountSummary[metric_key]); - } - return this.accountSummary[metric_key]; - }; - }, - displayInfoText() { - return metric_key => { - if (this.metrics[this.currentSelection].KEY !== metric_key) { - return ''; - } - if (this.isAverageMetricType(metric_key)) { - const total = this.accountReport.data - .map(item => item.count) - .reduce((prev, curr) => prev + curr, 0); - return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`; - } - return ''; - }; - }, - isAverageMetricType() { - return metric_key => { - return ['avg_first_response_time', 'avg_resolution_time'].includes( - metric_key + (diff / this.accountSummary.previous[metric_key]) * 100 ); }; }, }, + methods: { + displayMetric(key) { + if (this.isAverageMetricType(key)) { + return formatTime(this.accountSummary[key]); + } + return Number(this.accountSummary[key] || '').toLocaleString(); + }, + displayInfoText(key) { + if (this.metrics[this.currentSelection].KEY !== key) { + return ''; + } + if (this.isAverageMetricType(key)) { + const total = this.accountReport.data + .map(item => item.count) + .reduce((prev, curr) => prev + curr, 0); + return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`; + } + return ''; + }, + isAverageMetricType(key) { + return ['avg_first_response_time', 'avg_resolution_time'].includes(key); + }, + }, }; diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index aa3c451d9..d981de4e9 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -23,7 +23,7 @@ describe('reportMixin', () => { mixins: [reportMixin], }; const wrapper = shallowMount(Component, { store, localVue }); - expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5); + expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000'); expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( '3 Min 18 Sec' ); @@ -36,7 +36,7 @@ describe('reportMixin', () => { mixins: [reportMixin], }; const wrapper = shallowMount(Component, { store, localVue }); - expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); + expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(124900); expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); }); diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index 8402c3940..ab6b6fecf 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -2,7 +2,7 @@ export default { summary: { avg_first_response_time: '198.6666666666667', avg_resolution_time: '208.3333333333333', - conversations_count: 5, + conversations_count: 5000, incoming_messages_count: 5, outgoing_messages_count: 3, previous: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index d38e983a7..b04216bff 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -13,36 +13,7 @@ :show-group-by-filter="true" @filter-change="onFilterChange" /> -
- -
-
- -
- - - {{ $t('REPORT.NO_ENOUGH_DATA') }} - -
-
+ @@ -51,11 +22,11 @@ import { mapGetters } from 'vuex'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import ReportFilterSelector from './components/FilterSelector'; -import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; +import { GROUP_BY_FILTER } from './constants'; import reportMixin from 'dashboard/mixins/reportMixin'; import alertMixin from 'shared/mixins/alertMixin'; -import { formatTime } from '@chatwoot/utils'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; +import ReportContainer from './ReportContainer.vue'; const REPORTS_KEYS = { CONVERSATIONS: 'conversations_count', @@ -70,13 +41,13 @@ export default { name: 'ConversationReports', components: { ReportFilterSelector, + ReportContainer, }, mixins: [reportMixin, alertMixin], data() { return { from: 0, to: 0, - currentSelection: 0, groupBy: GROUP_BY_FILTER[1], businessHours: false, }; @@ -86,104 +57,6 @@ export default { accountSummary: 'getAccountSummary', accountReport: 'getAccountReports', }), - collection() { - if (this.accountReport.isFetching) { - return {}; - } - if (!this.accountReport.data.length) return {}; - const labels = this.accountReport.data.map(element => { - if (this.groupBy?.period === GROUP_BY_FILTER[2].period) { - let week_date = new Date(fromUnixTime(element.timestamp)); - const first_day = week_date.getDate() - week_date.getDay(); - const last_day = first_day + 6; - - const week_first_date = new Date(week_date.setDate(first_day)); - const week_last_date = new Date(week_date.setDate(last_day)); - - return `${format(week_first_date, 'dd/MM/yy')} - ${format( - week_last_date, - 'dd/MM/yy' - )}`; - } - if (this.groupBy?.period === GROUP_BY_FILTER[3].period) { - return format(fromUnixTime(element.timestamp), 'MMM-yyyy'); - } - if (this.groupBy?.period === GROUP_BY_FILTER[4].period) { - return format(fromUnixTime(element.timestamp), 'yyyy'); - } - return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); - }); - - const datasets = METRIC_CHART[ - this.metrics[this.currentSelection].KEY - ].datasets.map(dataset => { - switch (dataset.type) { - case 'bar': - return { - ...dataset, - yAxisID: 'y-left', - label: this.metrics[this.currentSelection].NAME, - data: this.accountReport.data.map(element => element.value), - }; - case 'line': - return { - ...dataset, - yAxisID: 'y-right', - label: this.metrics[0].NAME, - data: this.accountReport.data.map(element => element.count), - }; - default: - return dataset; - } - }); - - return { - labels, - datasets, - }; - }, - chartOptions() { - let tooltips = {}; - if (this.isAverageMetricType(this.metrics[this.currentSelection].KEY)) { - tooltips.callbacks = { - label: tooltipItem => { - return this.$t(this.metrics[this.currentSelection].TOOLTIP_TEXT, { - metricValue: formatTime(tooltipItem.yLabel), - conversationCount: this.accountReport.data[tooltipItem.index] - .count, - }); - }, - }; - } - - return { - scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales, - tooltips: tooltips, - }; - }, - metrics() { - const reportKeys = [ - 'CONVERSATIONS', - 'INCOMING_MESSAGES', - 'OUTGOING_MESSAGES', - 'FIRST_RESPONSE_TIME', - 'RESOLUTION_TIME', - 'RESOLUTION_COUNT', - ]; - const infoText = { - FIRST_RESPONSE_TIME: this.$t( - `REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT` - ), - RESOLUTION_TIME: this.$t(`REPORT.METRICS.RESOLUTION_TIME.INFO_TEXT`), - }; - return reportKeys.map(key => ({ - NAME: this.$t(`REPORT.METRICS.${key}.NAME`), - KEY: REPORTS_KEYS[key], - DESC: this.$t(`REPORT.METRICS.${key}.DESC`), - INFO_TEXT: infoText[key], - TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`, - })); - }, }, methods: { fetchAllData() { @@ -198,14 +71,23 @@ export default { } }, fetchChartData() { - try { - this.$store.dispatch('fetchAccountReport', { - metric: this.metrics[this.currentSelection].KEY, - ...this.getRequestPayload(), - }); - } catch { - this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED')); - } + [ + 'CONVERSATIONS', + 'INCOMING_MESSAGES', + 'OUTGOING_MESSAGES', + 'FIRST_RESPONSE_TIME', + 'RESOLUTION_TIME', + 'RESOLUTION_COUNT', + ].forEach(async key => { + try { + await this.$store.dispatch('fetchAccountReport', { + metric: REPORTS_KEYS[key], + ...this.getRequestPayload(), + }); + } catch { + this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED')); + } + }); }, getRequestPayload() { const { from, to, groupBy, businessHours } = this; @@ -225,10 +107,6 @@ export default { )}.csv`; this.$store.dispatch('downloadAgentReports', { from, to, fileName }); }, - changeSelection(index) { - this.currentSelection = index; - this.fetchChartData(); - }, onFilterChange({ from, to, groupBy, businessHours }) { this.from = from; this.to = to; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue new file mode 100644 index 000000000..0fa504a36 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -0,0 +1,156 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue new file mode 100644 index 000000000..400452f0b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue @@ -0,0 +1,49 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue index b0e032c0a..84bae6198 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue @@ -1,43 +1,45 @@