mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
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:
@@ -12,6 +12,7 @@ const reports = accountId => ({
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
'team_reports',
|
||||
'sla_reports',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
@@ -71,6 +72,14 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/teams`),
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -46,6 +46,11 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
conversationLabels: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -508,15 +508,29 @@
|
||||
},
|
||||
"SLA_REPORTS": {
|
||||
"HEADER": "SLA Reports",
|
||||
"NO_RECORDS": "SLA applied conversations are not available.",
|
||||
"LOADING": "Loading SLA data...",
|
||||
"METRICS": {
|
||||
"HIT_RATE": {
|
||||
"LABEL": "Hit Rate",
|
||||
"TOOLTIP": "Percentage of SLAs created were completed successfully"
|
||||
},
|
||||
"NO_OF_BREACHES": {
|
||||
"LABEL": "Number of Breaches",
|
||||
"TOOLTIP": "The total SLA breaches in a certain period."
|
||||
"NO_OF_MISSES": {
|
||||
"LABEL": "Number of Misses",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
|
||||
@@ -4,6 +4,10 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({ accountLabels: 'labels/getLabels' }),
|
||||
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'](
|
||||
this.conversationId
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,8 @@
|
||||
<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">
|
||||
<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" />
|
||||
<woot-date-range-picker
|
||||
v-if="isDateRangeSelected"
|
||||
|
||||
@@ -13,9 +13,18 @@
|
||||
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_BREACHES.LABEL')"
|
||||
:label="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.LABEL')"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,6 +41,10 @@ defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
noOfConversations: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -9,6 +9,7 @@ const TeamReports = () => import('./TeamReports.vue');
|
||||
const CsatResponses = () => import('./CsatResponses.vue');
|
||||
const BotReports = () => import('./BotReports.vue');
|
||||
const LiveReports = () => import('./LiveReports.vue');
|
||||
const SLAReports = () => import('./SLAReports.vue');
|
||||
|
||||
export default {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import SLAReportsAPI from '../../api/slaReports';
|
||||
export const state = {
|
||||
records: [],
|
||||
metrics: {
|
||||
numberOfSLABreaches: 0,
|
||||
numberOfConversations: 0,
|
||||
numberOfSLAMisses: 0,
|
||||
hitRate: '0%',
|
||||
},
|
||||
uiFlags: {
|
||||
@@ -72,19 +73,21 @@ export const mutations = {
|
||||
[types.SET_SLA_REPORTS]: MutationHelpers.set,
|
||||
[types.SET_SLA_REPORTS_METRICS](
|
||||
_state,
|
||||
{ number_of_sla_breaches: numberOfSLABreaches, hit_rate: hitRate }
|
||||
{
|
||||
number_of_sla_misses: numberOfSLAMisses,
|
||||
hit_rate: hitRate,
|
||||
total_applied_slas: numberOfConversations,
|
||||
}
|
||||
) {
|
||||
_state.metrics = {
|
||||
numberOfSLABreaches,
|
||||
numberOfSLAMisses,
|
||||
hitRate,
|
||||
numberOfConversations,
|
||||
};
|
||||
},
|
||||
[types.SET_SLA_REPORTS_META](
|
||||
_state,
|
||||
{ total_applied_slas: totalAppliedSLAs, current_page: currentPage }
|
||||
) {
|
||||
[types.SET_SLA_REPORTS_META](_state, { count, current_page: currentPage }) {
|
||||
_state.meta = {
|
||||
count: totalAppliedSLAs,
|
||||
count,
|
||||
currentPage,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -21,4 +21,33 @@ describe('#getters', () => {
|
||||
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%',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,14 @@ describe('#mutations', () => {
|
||||
it('set metrics', () => {
|
||||
const state = { metrics: {} };
|
||||
mutations[types.SET_SLA_REPORTS_METRICS](state, {
|
||||
number_of_sla_breaches: 1,
|
||||
number_of_sla_misses: 1,
|
||||
hit_rate: '100%',
|
||||
total_applied_slas: 1,
|
||||
});
|
||||
expect(state.metrics).toEqual({
|
||||
numberOfSLABreaches: 1,
|
||||
numberOfSLAMisses: 1,
|
||||
hitRate: '100%',
|
||||
numberOfConversations: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,7 +39,7 @@ describe('#mutations', () => {
|
||||
it('set meta', () => {
|
||||
const state = { meta: {} };
|
||||
mutations[types.SET_SLA_REPORTS_META](state, {
|
||||
total_applied_slas: 1,
|
||||
count: 1,
|
||||
current_page: 1,
|
||||
});
|
||||
expect(state.meta).toEqual({
|
||||
|
||||
@@ -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_current_page, only: [:index]
|
||||
before_action :paginate_slas, only: [:index]
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
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
|
||||
@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
|
||||
end
|
||||
|
||||
def download
|
||||
@breached_slas = breached_slas
|
||||
|
||||
@missed_applied_slas = missed_applied_slas
|
||||
response.headers['Content-Type'] = 'text/csv'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv'
|
||||
render layout: false, formats: [:csv]
|
||||
@@ -29,41 +30,38 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
|
||||
|
||||
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
|
||||
@total_applied_slas ||= @applied_slas.count
|
||||
end
|
||||
|
||||
def number_of_sla_breaches
|
||||
@number_of_sla_breaches ||= @applied_slas.missed.count
|
||||
def number_of_sla_misses
|
||||
@number_of_sla_misses ||= missed_applied_slas.count
|
||||
end
|
||||
|
||||
def hit_rate
|
||||
number_of_sla_breaches.zero? ? '100%' : "#{hit_rate_percentage}%"
|
||||
number_of_sla_misses.zero? ? '100%' : "#{hit_rate_percentage}%"
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def set_applied_slas
|
||||
initial_query = Current.account.applied_slas.includes(:conversation)
|
||||
@applied_slas = 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])
|
||||
@applied_slas = apply_filters(initial_query)
|
||||
end
|
||||
|
||||
def paginate_slas
|
||||
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
def apply_filters(query)
|
||||
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
|
||||
|
||||
def set_current_page
|
||||
|
||||
@@ -39,7 +39,7 @@ class AppliedSla < ApplicationRecord
|
||||
joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id })
|
||||
end
|
||||
}
|
||||
scope :missed, -> { where(sla_status: :missed) }
|
||||
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
|
||||
|
||||
after_update_commit :push_conversation_event
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
] %>
|
||||
<%= CSV.generate_line headers %>
|
||||
|
||||
<% @breached_slas.each do |sla| %>
|
||||
<% breached_events = sla.sla_events.map(&:event_type).join(', ') %>
|
||||
<% @missed_applied_slas.each do |sla| %>
|
||||
<% missed_events = sla.sla_events.map(&:event_type).join(', ') %>
|
||||
<% conversation = sla.conversation %>
|
||||
<%= CSV.generate_line([
|
||||
conversation.display_id,
|
||||
@@ -21,6 +21,6 @@
|
||||
conversation.inbox&.name,
|
||||
conversation.cached_label_list,
|
||||
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id),
|
||||
breached_events
|
||||
missed_events
|
||||
]) %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
json.array! @applied_slas do |applied_sla|
|
||||
json.id applied_sla.id
|
||||
json.sla_policy_id applied_sla.sla_policy_id
|
||||
json.conversation_id applied_sla.conversation_id
|
||||
json.sla_status applied_sla.sla_status
|
||||
json.created_at applied_sla.created_at
|
||||
json.updated_at applied_sla.updated_at
|
||||
json.conversation do
|
||||
json.partial! 'api/v1/models/conversation', conversation: applied_sla.conversation
|
||||
end
|
||||
json.sla_events applied_sla.sla_events do |sla_event|
|
||||
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event
|
||||
json.payload do
|
||||
json.array! @applied_slas do |applied_sla|
|
||||
json.applied_sla applied_sla.push_event_data
|
||||
json.conversation do
|
||||
conversation = applied_sla.conversation
|
||||
json.id conversation.id
|
||||
json.contact do
|
||||
json.name conversation.contact.name if conversation.contact
|
||||
end
|
||||
json.labels conversation.cached_label_list
|
||||
json.assignee conversation.assignee.push_event_data if conversation.assignee
|
||||
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
|
||||
|
||||
json.meta do
|
||||
json.count @count
|
||||
json.current_page @current_page
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "public/packs/js/widget-*.js",
|
||||
"limit": "280 KB"
|
||||
"limit": "281 KB"
|
||||
},
|
||||
{
|
||||
"path": "public/packs/js/sdk.js",
|
||||
|
||||
@@ -33,7 +33,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
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%')
|
||||
end
|
||||
|
||||
@@ -48,7 +48,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
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%')
|
||||
end
|
||||
|
||||
@@ -64,7 +64,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
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%')
|
||||
end
|
||||
|
||||
@@ -80,7 +80,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
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%')
|
||||
end
|
||||
|
||||
@@ -98,7 +98,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
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%')
|
||||
end
|
||||
end
|
||||
@@ -128,7 +128,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
|
||||
csv_data = CSV.parse(response.body)
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -145,21 +145,20 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns the applied slas' do
|
||||
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",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body.size).to eq(2)
|
||||
expect(body.first).to include('id')
|
||||
expect(body.first).to include('sla_policy_id' => sla_policy1.id)
|
||||
expect(body.first).to include('conversation_id' => conversation1.id)
|
||||
expect(body['payload'].size).to eq(1)
|
||||
expect(body['payload'].first).to include('applied_sla')
|
||||
expect(body['payload'].first['conversation']['id']).to eq(conversation2.id)
|
||||
expect(body['meta']).to include('count' => 1)
|
||||
end
|
||||
|
||||
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: conversation2, created_at: 3.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, sla_status: 'missed')
|
||||
|
||||
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 },
|
||||
@@ -167,13 +166,13 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body.size).to eq(1)
|
||||
expect(body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
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: conversation3, created_at: 3.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, 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, sla_status: 'active_with_misses')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
params: { agent_ids: [agent2.id] },
|
||||
@@ -181,13 +180,13 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body.size).to eq(3)
|
||||
expect(body['payload'].size).to eq(2)
|
||||
end
|
||||
|
||||
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_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",
|
||||
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)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body.size).to eq(2)
|
||||
expect(body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
it 'filters applied slas based on labels' do
|
||||
conversation2.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: conversation2, created_at: 3.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.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, sla_status: 'missed')
|
||||
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",
|
||||
params: { label_list: ['label1'] },
|
||||
@@ -211,7 +210,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body.size).to eq(2)
|
||||
expect(body['payload'].size).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user