feat: SLA reports view (#9189)

* feat: SLA report table


* feat: Add SLA popover card

* feat: Update popover position

* feat: Add loader

* Update SLACardLabel.vue

* feat: Update column order

* chore: fix conditions

* Update SLATable.vue

* chore: enable reports in ui

* chore: Revamp report SLA apis

* chore: revert download method

* chore: improve the code

* Update enterprise/app/views/api/v1/accounts/applied_slas/download.csv.erb

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>

* chore: style fixes

* chore: fix specs

* feat: Add number of conversations

* chore: review comments

* fix: translation

* Update app/javascript/dashboard/i18n/locale/en/report.json

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>

* Update app/javascript/dashboard/i18n/locale/en/report.json

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>

* Update app/javascript/dashboard/i18n/locale/en/report.json

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>

* Update SLAReportItem.vue

* Update report.json

* Update package.json

* chore: review comments

* chore: remove unused translation

* feat: Add TableHeaderCell component

* chore: more review fixes

* Update app/javascript/dashboard/components/widgets/TableHeaderCell.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Update TableHeaderCell.vue

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Muhsin Keloth
2024-04-09 05:44:36 +05:30
committed by GitHub
parent 7ae09ce1bf
commit 1038d1500e
24 changed files with 623 additions and 84 deletions

View File

@@ -12,6 +12,7 @@ const reports = accountId => ({
'label_reports', 'label_reports',
'inbox_reports', 'inbox_reports',
'team_reports', 'team_reports',
'sla_reports',
], ],
menuItems: [ menuItems: [
{ {
@@ -71,6 +72,14 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/teams`), toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports', toStateName: 'team_reports',
}, },
{
icon: 'document-list-clock',
label: 'REPORTS_SLA',
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.SLA,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
toStateName: 'sla_reports',
},
], ],
}); });

View File

@@ -0,0 +1,39 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
span: {
type: Number,
required: true,
},
label: {
type: String,
required: true,
default: '',
},
});
const spanClass = computed(() => {
if (props.span === 1) return 'col-span-1';
if (props.span === 2) return 'col-span-2';
if (props.span === 3) return 'col-span-3';
if (props.span === 4) return 'col-span-4';
if (props.span === 5) return 'col-span-5';
if (props.span === 6) return 'col-span-6';
if (props.span === 7) return 'col-span-7';
if (props.span === 8) return 'col-span-8';
if (props.span === 9) return 'col-span-9';
if (props.span === 10) return 'col-span-10';
return 'col-span-1';
});
</script>
<template>
<div
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
:class="spanClass"
>
<slot>
{{ label }}
</slot>
</div>
</template>

View File

@@ -46,6 +46,11 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
conversationLabels: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {

View File

@@ -508,15 +508,29 @@
}, },
"SLA_REPORTS": { "SLA_REPORTS": {
"HEADER": "SLA Reports", "HEADER": "SLA Reports",
"NO_RECORDS": "SLA applied conversations are not available.",
"LOADING": "Loading SLA data...",
"METRICS": { "METRICS": {
"HIT_RATE": { "HIT_RATE": {
"LABEL": "Hit Rate", "LABEL": "Hit Rate",
"TOOLTIP": "Percentage of SLAs created were completed successfully" "TOOLTIP": "Percentage of SLAs created were completed successfully"
}, },
"NO_OF_BREACHES": { "NO_OF_MISSES": {
"LABEL": "Number of Breaches", "LABEL": "Number of Misses",
"TOOLTIP": "The total SLA breaches in a certain period." "TOOLTIP": "Total SLA misses in a certain period"
},
"NO_OF_CONVERSATIONS": {
"LABEL": "Number of Conversations",
"TOOLTIP": "Total number of conversations with SLA"
} }
},
"TABLE": {
"HEADER": {
"POLICY": "Policy",
"CONVERSATION": "Conversation",
"AGENT": "Agent"
},
"VIEW_DETAILS": "View Details"
} }
} }
} }

View File

@@ -240,6 +240,7 @@
"CAMPAIGNS": "Campaigns", "CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing", "ONGOING": "Ongoing",
"ONE_OFF": "One off", "ONE_OFF": "One off",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Bot", "REPORTS_BOT": "Bot",
"REPORTS_AGENT": "Agents", "REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels", "REPORTS_LABEL": "Labels",

View File

@@ -4,6 +4,10 @@ export default {
computed: { computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }), ...mapGetters({ accountLabels: 'labels/getLabels' }),
savedLabels() { savedLabels() {
// If conversationLabels is passed as prop, use it
if (this.conversationLabels)
return this.conversationLabels.split(',').map(item => item.trim());
// Otherwise, get labels from store
return this.$store.getters['conversationLabels/getConversationLabels']( return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId this.conversationId
); );

View File

@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col flex-1 px-4 pt-4 overflow-auto">
<SLAReportFilters @filter-change="onFilterChange" />
<div class="flex flex-col gap-6">
<SLAMetrics
:hit-rate="slaMetrics.hitRate"
:no-of-breaches="slaMetrics.numberOfSLAMisses"
:no-of-conversations="slaMetrics.numberOfConversations"
:is-loading="uiFlags.isFetchingMetrics"
/>
<SLATable
:sla-reports="slaReports"
:is-loading="uiFlags.isFetching"
:current-page="Number(slaMeta.currentPage)"
:total-count="Number(slaMeta.count)"
@page-change="onPageChange"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import SLAMetrics from './components/SLA/SLAMetrics.vue';
import SLATable from './components/SLA/SLATable.vue';
import SLAReportFilters from './components/SLA/SLAReportFilters.vue';
export default {
name: 'SLAReports',
components: {
SLAMetrics,
SLATable,
SLAReportFilters,
},
data() {
return {
pageNumber: 1,
from: 0,
to: 0,
};
},
computed: {
...mapGetters({
slaReports: 'slaReports/getAll',
slaMetrics: 'slaReports/getMetrics',
slaMeta: 'slaReports/getMeta',
uiFlags: 'slaReports/getUIFlags',
}),
},
mounted() {
this.fetchSLAMetrics();
this.fetchSLAReports();
},
methods: {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
from: this.from,
to: this.to,
});
},
fetchSLAMetrics() {
this.$store.dispatch('slaReports/getMetrics', {
from: this.from,
to: this.to,
});
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
onFilterChange({ from, to }) {
this.from = from;
this.to = to;
this.fetchSLAReports();
this.fetchSLAMetrics();
},
},
};
</script>

View File

@@ -1,6 +1,8 @@
<template> <template>
<div class="flex flex-col md:flex-row justify-between mb-4"> <div class="flex flex-col md:flex-row justify-between mb-4">
<div class="md:grid flex flex-col filter-container gap-3 w-full"> <div
class="md:grid flex-col gap-3 w-full grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] p-5"
>
<reports-filters-date-range @on-range-change="onDateRangeChange" /> <reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker <woot-date-range-picker
v-if="isDateRangeSelected" v-if="isDateRangeSelected"

View File

@@ -13,9 +13,18 @@
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50" class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
/> />
<SLAMetricCard <SLAMetricCard
:label="$t('SLA_REPORTS.METRICS.NO_OF_BREACHES.LABEL')" :label="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.LABEL')"
:value="noOfBreaches" :value="noOfBreaches"
:tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_BREACHES.TOOLTIP')" :tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.TOOLTIP')"
:is-loading="isLoading"
/>
<div
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
/>
<SLAMetricCard
:label="$t('SLA_REPORTS.METRICS.NO_OF_CONVERSATIONS.LABEL')"
:value="noOfConversations"
:tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_CONVERSATIONS.TOOLTIP')"
:is-loading="isLoading" :is-loading="isLoading"
/> />
</div> </div>
@@ -32,6 +41,10 @@ defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
noOfConversations: {
type: Number,
required: true,
},
isLoading: { isLoading: {
type: Boolean, type: Boolean,
default: false, default: false,

View File

@@ -0,0 +1,91 @@
<template>
<div class="flex flex-col md:flex-row justify-between mb-4">
<div class="md:grid flex flex-col filter-container gap-3 w-full">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
</div>
</div>
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from '../Filters/DateRange.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
},
data() {
return {
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
selectedGroupByFilter: null,
customDateRange: [new Date(), new Date()],
};
},
computed: {
isDateRangeSelected() {
return (
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
);
},
to() {
if (this.isDateRangeSelected) {
return getUnixEndOfDay(this.customDateRange[1]);
}
return getUnixEndOfDay(new Date());
},
from() {
if (this.isDateRangeSelected) {
return getUnixStartOfDay(this.customDateRange[0]);
}
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
return getUnixStartOfDay(fromDate);
},
},
watch: {
businessHoursSelected() {
this.emitChange();
},
},
mounted() {
this.emitChange();
},
methods: {
emitChange() {
const { from, to } = this;
this.$emit('filter-change', {
from,
to,
});
},
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.emitChange();
},
onCustomDateRangeChange(value) {
this.customDateRange = value;
this.emitChange();
},
},
};
</script>
<style scoped>
.filter-container {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import CardLabels from 'dashboard/components/widgets/conversation/conversationCardComponents/CardLabels.vue';
import SLAViewDetails from './SLAViewDetails.vue';
defineProps({
slaName: {
type: String,
required: true,
},
conversationId: {
type: Number,
required: true,
},
conversation: {
type: Object,
required: true,
},
slaEvents: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div
class="grid content-center items-center h-16 grid-cols-12 gap-4 px-6 py-0 w-full bg-white border-b last:border-b-0 last:rounded-b-xl border-slate-75 dark:border-slate-800/50 dark:bg-slate-900"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
{{ `#${conversationId} ` }}
</span>
<span class="text-slate-600 dark:text-slate-300">with </span>
<span class="text-slate-700 dark:text-slate-200 capitalize truncate">{{
conversation.contact.name
}}</span>
<card-labels
class="w-[80%]"
:conversation-id="conversationId"
:conversation-labels="conversation.labels"
/>
</div>
<div
class="flex items-center capitalize py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right col-span-2"
>
{{ slaName }}
</div>
<div class="flex items-center gap-2 col-span-2">
<user-avatar-with-name
v-if="conversation.assignee"
:user="conversation.assignee"
/>
<span v-else class="text-slate-600 dark:text-slate-200"> --- </span>
</div>
<SLAViewDetails :sla-events="slaEvents" />
</div>
</template>

View File

@@ -0,0 +1,112 @@
<template>
<div>
<div
class="min-w-full border rounded-xl border-slate-75 dark:border-slate-700/50"
>
<div
class="grid content-center h-12 grid-cols-12 gap-4 px-6 py-0 border-b bg-slate-25 border-slate-75 dark:border-slate-800 rounded-t-xl dark:bg-slate-900"
>
<table-header-cell
:span="6"
:label="$t('SLA_REPORTS.TABLE.HEADER.CONVERSATION')"
/>
<table-header-cell
:span="2"
:label="$t('SLA_REPORTS.TABLE.HEADER.POLICY')"
/>
<table-header-cell
:span="2"
:label="$t('SLA_REPORTS.TABLE.HEADER.AGENT')"
/>
<table-header-cell :span="2" label="" />
</div>
<div
v-if="isLoading"
class="flex items-center justify-center h-32 bg-white dark:bg-slate-900"
>
<spinner />
<span>{{ $t('SLA_REPORTS.LOADING') }}</span>
</div>
<div v-else-if="slaReports.length > 0">
<SLA-report-item
v-for="slaReport in slaReports"
:key="slaReport.applied_sla.id"
:sla-name="slaReport.applied_sla.sla_name"
:conversation="slaReport.conversation"
:conversation-id="slaReport.conversation.id"
:sla-events="slaReport.sla_events"
/>
</div>
<div
v-else
class="flex items-center justify-center h-32 bg-white dark:bg-slate-900"
>
{{ $t('SLA_REPORTS.NO_RECORDS') }}
</div>
</div>
<table-footer
v-if="shouldShowFooter"
:current-page="currentPage"
:total-count="totalCount"
:page-size="pageSize"
class="bg-slate-25 dark:bg-slate-900 sticky bottom-0 border-none mt-4"
@page-change="onPageChange"
/>
</div>
</template>
<script>
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
import SLAReportItem from './SLAReportItem.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
name: 'SLATable',
components: {
SLAReportItem,
TableFooter,
Spinner,
TableHeaderCell,
},
props: {
slaReports: {
type: Array,
default: () => [],
},
totalCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
isLoading: {
type: Boolean,
default: false,
},
},
data() {
return {
pageNo: 1,
};
},
computed: {
shouldShowFooter() {
return this.currentPage === 1
? this.totalCount > this.pageSize
: this.slaReports.length > 0;
},
},
methods: {
onPageChange(page) {
this.$emit('page-change', page);
},
},
};
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div v-on-clickaway="closeSlaEvents" class="label-wrap">
<div
class="flex items-center col-span-2 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<div class="relative">
<woot-button
color-scheme="secondary"
variant="link"
@click="openSlaEvents"
>
{{ $t('SLA_REPORTS.TABLE.VIEW_DETAILS') }}
</woot-button>
<SLA-popover-card
v-if="showSlaPopoverCard"
:all-missed-slas="slaEvents"
class="right-0"
/>
</div>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import SLAPopoverCard from 'dashboard/components/widgets/conversation/components/SLAPopoverCard.vue';
export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
slaEvents: {
type: Array,
default: () => [],
},
},
data() {
return {
showSlaPopoverCard: false,
};
},
methods: {
closeSlaEvents() {
this.showSlaPopoverCard = false;
},
openSlaEvents() {
this.showSlaPopoverCard = !this.showSlaPopoverCard;
},
},
};
</script>

View File

@@ -9,6 +9,7 @@ const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue'); const CsatResponses = () => import('./CsatResponses.vue');
const BotReports = () => import('./BotReports.vue'); const BotReports = () => import('./BotReports.vue');
const LiveReports = () => import('./LiveReports.vue'); const LiveReports = () => import('./LiveReports.vue');
const SLAReports = () => import('./SLAReports.vue');
export default { export default {
routes: [ routes: [
@@ -151,5 +152,22 @@ export default {
}, },
], ],
}, },
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'SLA_REPORTS.HEADER',
icon: 'document-list-clock',
keepAlive: false,
},
children: [
{
path: 'sla',
name: 'sla_reports',
roles: ['administrator'],
component: SLAReports,
},
],
},
], ],
}; };

View File

@@ -5,7 +5,8 @@ import SLAReportsAPI from '../../api/slaReports';
export const state = { export const state = {
records: [], records: [],
metrics: { metrics: {
numberOfSLABreaches: 0, numberOfConversations: 0,
numberOfSLAMisses: 0,
hitRate: '0%', hitRate: '0%',
}, },
uiFlags: { uiFlags: {
@@ -72,19 +73,21 @@ export const mutations = {
[types.SET_SLA_REPORTS]: MutationHelpers.set, [types.SET_SLA_REPORTS]: MutationHelpers.set,
[types.SET_SLA_REPORTS_METRICS]( [types.SET_SLA_REPORTS_METRICS](
_state, _state,
{ number_of_sla_breaches: numberOfSLABreaches, hit_rate: hitRate } {
number_of_sla_misses: numberOfSLAMisses,
hit_rate: hitRate,
total_applied_slas: numberOfConversations,
}
) { ) {
_state.metrics = { _state.metrics = {
numberOfSLABreaches, numberOfSLAMisses,
hitRate, hitRate,
numberOfConversations,
}; };
}, },
[types.SET_SLA_REPORTS_META]( [types.SET_SLA_REPORTS_META](_state, { count, current_page: currentPage }) {
_state,
{ total_applied_slas: totalAppliedSLAs, current_page: currentPage }
) {
_state.meta = { _state.meta = {
count: totalAppliedSLAs, count,
currentPage, currentPage,
}; };
}, },

View File

@@ -21,4 +21,33 @@ describe('#getters', () => {
isFetchingMetrics: false, isFetchingMetrics: false,
}); });
}); });
it('getMeta', () => {
const state = {
meta: {
count: 0,
currentPage: 1,
},
};
expect(getters.getMeta(state)).toEqual({
count: 0,
currentPage: 1,
});
});
it('getMetrics', () => {
const state = {
metrics: {
numberOfConversations: 27,
numberOfSLAMisses: 25,
hitRate: '7.41%',
},
};
expect(getters.getMetrics(state)).toEqual({
numberOfConversations: 27,
numberOfSLAMisses: 25,
hitRate: '7.41%',
});
});
}); });

View File

@@ -23,12 +23,14 @@ describe('#mutations', () => {
it('set metrics', () => { it('set metrics', () => {
const state = { metrics: {} }; const state = { metrics: {} };
mutations[types.SET_SLA_REPORTS_METRICS](state, { mutations[types.SET_SLA_REPORTS_METRICS](state, {
number_of_sla_breaches: 1, number_of_sla_misses: 1,
hit_rate: '100%', hit_rate: '100%',
total_applied_slas: 1,
}); });
expect(state.metrics).toEqual({ expect(state.metrics).toEqual({
numberOfSLABreaches: 1, numberOfSLAMisses: 1,
hitRate: '100%', hitRate: '100%',
numberOfConversations: 1,
}); });
}); });
}); });
@@ -37,7 +39,7 @@ describe('#mutations', () => {
it('set meta', () => { it('set meta', () => {
const state = { meta: {} }; const state = { meta: {} };
mutations[types.SET_SLA_REPORTS_META](state, { mutations[types.SET_SLA_REPORTS_META](state, {
total_applied_slas: 1, count: 1,
current_page: 1, current_page: 1,
}); });
expect(state.meta).toEqual({ expect(state.meta).toEqual({

View File

@@ -6,22 +6,23 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
before_action :set_applied_slas, only: [:index, :metrics, :download] before_action :set_applied_slas, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
before_action :paginate_slas, only: [:index]
before_action :check_admin_authorization? before_action :check_admin_authorization?
sort_on :created_at, type: :datetime sort_on :created_at, type: :datetime
def index; end def index
@count = number_of_sla_misses
@applied_slas = @missed_applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def metrics def metrics
@total_applied_slas = total_applied_slas @total_applied_slas = total_applied_slas
@number_of_sla_breaches = number_of_sla_breaches @number_of_sla_misses = number_of_sla_misses
@hit_rate = hit_rate @hit_rate = hit_rate
end end
def download def download
@breached_slas = breached_slas @missed_applied_slas = missed_applied_slas
response.headers['Content-Type'] = 'text/csv' response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv' response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv'
render layout: false, formats: [:csv] render layout: false, formats: [:csv]
@@ -29,41 +30,38 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
private private
def breached_slas
@applied_slas.includes(:sla_policy).joins(:conversation)
.where.not(conversations: { status: :resolved })
.where(applied_slas: { sla_status: :missed })
end
def total_applied_slas def total_applied_slas
@total_applied_slas ||= @applied_slas.count @total_applied_slas ||= @applied_slas.count
end end
def number_of_sla_breaches def number_of_sla_misses
@number_of_sla_breaches ||= @applied_slas.missed.count @number_of_sla_misses ||= missed_applied_slas.count
end end
def hit_rate def hit_rate
number_of_sla_breaches.zero? ? '100%' : "#{hit_rate_percentage}%" number_of_sla_misses.zero? ? '100%' : "#{hit_rate_percentage}%"
end end
def hit_rate_percentage def hit_rate_percentage
((total_applied_slas - number_of_sla_breaches) / total_applied_slas.to_f * 100).round(2) ((total_applied_slas - number_of_sla_misses) / total_applied_slas.to_f * 100).round(2)
end end
def set_applied_slas def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation) initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = initial_query @applied_slas = apply_filters(initial_query)
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_sla_policy_id(params[:sla_policy_id])
.filter_by_label_list(params[:label_list])
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end end
def paginate_slas def apply_filters(query)
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE) query.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_sla_policy_id(params[:sla_policy_id])
.filter_by_label_list(params[:label_list])
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end
def missed_applied_slas
@missed_applied_slas ||= @applied_slas.missed
end end
def set_current_page def set_current_page

View File

@@ -39,7 +39,7 @@ class AppliedSla < ApplicationRecord
joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id }) joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id })
end end
} }
scope :missed, -> { where(sla_status: :missed) } scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
after_update_commit :push_conversation_event after_update_commit :push_conversation_event

View File

@@ -10,8 +10,8 @@
] %> ] %>
<%= CSV.generate_line headers %> <%= CSV.generate_line headers %>
<% @breached_slas.each do |sla| %> <% @missed_applied_slas.each do |sla| %>
<% breached_events = sla.sla_events.map(&:event_type).join(', ') %> <% missed_events = sla.sla_events.map(&:event_type).join(', ') %>
<% conversation = sla.conversation %> <% conversation = sla.conversation %>
<%= CSV.generate_line([ <%= CSV.generate_line([
conversation.display_id, conversation.display_id,
@@ -21,6 +21,6 @@
conversation.inbox&.name, conversation.inbox&.name,
conversation.cached_label_list, conversation.cached_label_list,
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id), app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id),
breached_events missed_events
]) %> ]) %>
<% end %> <% end %>

View File

@@ -1,14 +1,22 @@
json.array! @applied_slas do |applied_sla| json.payload do
json.id applied_sla.id json.array! @applied_slas do |applied_sla|
json.sla_policy_id applied_sla.sla_policy_id json.applied_sla applied_sla.push_event_data
json.conversation_id applied_sla.conversation_id json.conversation do
json.sla_status applied_sla.sla_status conversation = applied_sla.conversation
json.created_at applied_sla.created_at json.id conversation.id
json.updated_at applied_sla.updated_at json.contact do
json.conversation do json.name conversation.contact.name if conversation.contact
json.partial! 'api/v1/models/conversation', conversation: applied_sla.conversation end
end json.labels conversation.cached_label_list
json.sla_events applied_sla.sla_events do |sla_event| json.assignee conversation.assignee.push_event_data if conversation.assignee
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event end
json.sla_events applied_sla.sla_events do |sla_event|
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event
end
end end
end end
json.meta do
json.count @count
json.current_page @current_page
end

View File

@@ -1,3 +1,3 @@
json.total_applied_slas @total_applied_slas json.total_applied_slas @total_applied_slas
json.number_of_sla_breaches @number_of_sla_breaches json.number_of_sla_misses @number_of_sla_misses
json.hit_rate @hit_rate json.hit_rate @hit_rate

View File

@@ -22,7 +22,7 @@
"size-limit": [ "size-limit": [
{ {
"path": "public/packs/js/widget-*.js", "path": "public/packs/js/widget-*.js",
"limit": "280 KB" "limit": "281 KB"
}, },
{ {
"path": "public/packs/js/sdk.js", "path": "public/packs/js/sdk.js",

View File

@@ -33,7 +33,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 1) expect(body).to include('total_applied_slas' => 1)
expect(body).to include('number_of_sla_breaches' => 1) expect(body).to include('number_of_sla_misses' => 1)
expect(body).to include('hit_rate' => '0.0%') expect(body).to include('hit_rate' => '0.0%')
end end
@@ -48,7 +48,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 1) expect(body).to include('total_applied_slas' => 1)
expect(body).to include('number_of_sla_breaches' => 0) expect(body).to include('number_of_sla_misses' => 0)
expect(body).to include('hit_rate' => '100%') expect(body).to include('hit_rate' => '100%')
end end
@@ -64,7 +64,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 3) expect(body).to include('total_applied_slas' => 3)
expect(body).to include('number_of_sla_breaches' => 1) expect(body).to include('number_of_sla_misses' => 1)
expect(body).to include('hit_rate' => '66.67%') expect(body).to include('hit_rate' => '66.67%')
end end
@@ -80,7 +80,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 2) expect(body).to include('total_applied_slas' => 2)
expect(body).to include('number_of_sla_breaches' => 1) expect(body).to include('number_of_sla_misses' => 1)
expect(body).to include('hit_rate' => '50.0%') expect(body).to include('hit_rate' => '50.0%')
end end
@@ -98,7 +98,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 2) expect(body).to include('total_applied_slas' => 2)
expect(body).to include('number_of_sla_breaches' => 1) expect(body).to include('number_of_sla_misses' => 1)
expect(body).to include('hit_rate' => '50.0%') expect(body).to include('hit_rate' => '50.0%')
end end
end end
@@ -128,7 +128,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
csv_data = CSV.parse(response.body) csv_data = CSV.parse(response.body)
csv_data.reject! { |row| row.all?(&:nil?) } csv_data.reject! { |row| row.all?(&:nil?) }
expect(csv_data.size).to eq(2) expect(csv_data.size).to eq(3)
expect(csv_data[1][0].to_i).to eq(conversation1.display_id) expect(csv_data[1][0].to_i).to eq(conversation1.display_id)
end end
end end
@@ -145,21 +145,20 @@ RSpec.describe 'Applied SLAs API', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'returns the applied slas' do it 'returns the applied slas' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas", get "/api/v1/accounts/#{account.id}/applied_slas",
headers: administrator.create_new_auth_token headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body['payload'].size).to eq(1)
expect(body.size).to eq(2) expect(body['payload'].first).to include('applied_sla')
expect(body.first).to include('id') expect(body['payload'].first['conversation']['id']).to eq(conversation2.id)
expect(body.first).to include('sla_policy_id' => sla_policy1.id) expect(body['meta']).to include('count' => 1)
expect(body.first).to include('conversation_id' => conversation1.id)
end end
it 'filters applied slas based on a date range' do it 'filters applied slas based on a date range' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas", get "/api/v1/accounts/#{account.id}/applied_slas",
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s }, params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
@@ -167,13 +166,13 @@ RSpec.describe 'Applied SLAs API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body.size).to eq(1) expect(body['payload'].size).to eq(1)
end end
it 'filters applied slas based on a date range and agent ids' do it 'filters applied slas based on a date range and agent ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'active_with_misses')
get "/api/v1/accounts/#{account.id}/applied_slas", get "/api/v1/accounts/#{account.id}/applied_slas",
params: { agent_ids: [agent2.id] }, params: { agent_ids: [agent2.id] },
@@ -181,13 +180,13 @@ RSpec.describe 'Applied SLAs API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body.size).to eq(3) expect(body['payload'].size).to eq(2)
end end
it 'filters applied slas based on sla policy ids' do it 'filters applied slas based on sla policy ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2)
create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2) create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2, sla_status: 'active_with_misses')
get "/api/v1/accounts/#{account.id}/applied_slas", get "/api/v1/accounts/#{account.id}/applied_slas",
params: { sla_policy_id: sla_policy1.id }, params: { sla_policy_id: sla_policy1.id },
@@ -195,15 +194,15 @@ RSpec.describe 'Applied SLAs API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body.size).to eq(2) expect(body['payload'].size).to eq(1)
end end
it 'filters applied slas based on labels' do it 'filters applied slas based on labels' do
conversation2.update_labels('label1') conversation2.update_labels('label1')
conversation3.update_labels('label1') conversation3.update_labels('label1')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago, sla_status: 'active_with_misses')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas", get "/api/v1/accounts/#{account.id}/applied_slas",
params: { label_list: ['label1'] }, params: { label_list: ['label1'] },
@@ -211,7 +210,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body.size).to eq(2) expect(body['payload'].size).to eq(2)
end end
end end
end end