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

@@ -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>
<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"

View File

@@ -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,

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 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,
},
],
},
],
};