Compare commits

...
Author SHA1 Message Date
Muhsin Keloth 81d97b3727 Update the UI 2024-04-02 15:30:19 +05:30
Muhsin Keloth 013558e5af remove file 2024-04-02 15:00:29 +05:30
Muhsin Keloth 33d833497e Update SLAReports.vue 2024-04-02 14:09:42 +05:30
Muhsin Keloth 7937605794 style updates 2024-04-02 14:08:23 +05:30
Muhsin Keloth 2874df2c00 Update SLAMetrics.vue 2024-04-02 14:05:19 +05:30
Muhsin Keloth dcb1de642e feat: Add loader 2024-04-02 13:51:51 +05:30
Muhsin Keloth 1b527c63c1 update name 2024-04-01 22:10:13 +05:30
Muhsin Keloth fcd7460ca1 Update notification_listener.rb 2024-04-01 16:37:21 +05:30
Muhsin Keloth 3d7d68c1bd feat: Add assignee name 2024-04-01 11:25:51 +05:30
Muhsin Keloth 975ce54fd7 fix: navigation 2024-03-31 13:34:33 +05:30
Muhsin Keloth de982f2c9f feat: Add Avatar component 2024-03-31 13:02:55 +05:30
Muhsin Keloth 1935d45a42 feat: Add SLA selector 2024-03-31 10:45:36 +05:30
Muhsin Keloth 31e2356b29 Update SLAReportItem.vue 2024-03-29 15:27:13 +05:30
Muhsin Keloth cf1a8c4b17 feat: add labels 2024-03-29 13:59:16 +05:30
Muhsin Keloth 89cac2dec7 feat: Complete API binding 2024-03-29 12:27:52 +05:30
Muhsin Keloth 088795f0ad Merge branch 'feat/sla-reports-view' of https://github.com/chatwoot/chatwoot into feat/sla-reports-view 2024-03-29 10:03:15 +05:30
Muhsin Keloth 790077bd8f update component 2024-03-29 10:03:12 +05:30
Muhsin Keloth 596bebea5c Update SLAMetricCard.vue 2024-03-29 10:03:12 +05:30
Muhsin Keloth 7f1aff54ee Update SLATable.vue 2024-03-29 10:03:12 +05:30
Muhsin Keloth 13a1ccd85c SLAMetrics.vue 2024-03-29 10:03:12 +05:30
Muhsin Keloth 3853f860db feat: Add SLA table 2024-03-29 10:03:12 +05:30
Muhsin Keloth 8bc7ebb890 feat: Add SLA reports page 2024-03-29 10:03:12 +05:30
Muhsin Keloth 49b2815e96 update component 2024-03-29 10:00:47 +05:30
Muhsin Keloth 5a9a9e3b53 Update SLAMetricCard.vue 2024-03-28 15:12:45 +05:30
Muhsin Keloth e243eb619a Update SLATable.vue 2024-03-27 22:41:03 +05:30
Muhsin Keloth d4d5734a04 SLAMetrics.vue 2024-03-27 22:06:59 +05:30
Muhsin Keloth 5abe802198 feat: Add SLA table 2024-03-27 15:24:39 +05:30
Muhsin Keloth 5ab9efc464 feat: Add SLA reports page 2024-03-27 11:46:34 +05:30
25 changed files with 1013 additions and 20 deletions
@@ -0,0 +1,73 @@
/* global axios */
import ApiClient from './ApiClient';
class SLAReportsAPI extends ApiClient {
constructor() {
super('applied_slas', { accountScoped: true });
}
get({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
page,
} = {}) {
return axios.get(this.url, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
page,
},
});
}
download({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
} = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
},
});
}
getMetrics({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
} = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
},
});
}
}
export default new SLAReportsAPI();
@@ -12,6 +12,7 @@ const reports = accountId => ({
'label_reports',
'inbox_reports',
'team_reports',
'sla_reports',
],
menuItems: [
{
@@ -71,6 +72,13 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports',
},
{
icon: 'document-list-clock',
label: 'REPORTS_SLA',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
toStateName: 'sla_reports',
},
],
});
@@ -45,6 +45,11 @@ export default {
type: Number,
required: true,
},
conversationLabels: {
type: String,
required: false,
default: '',
},
},
data() {
return {
@@ -505,5 +505,26 @@
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
},
"SLA_REPORTS": {
"HEADER": "SLA Reports",
"NO_RECORDS": "There are no SLA reports available.",
"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."
}
},
"TABLE": {
"HEADER": {
"POLICY_BREACHED": "Policy breached",
"CONVERSATION": "Conversation",
"AGENT": "Agent"
}
}
}
}
@@ -239,6 +239,7 @@
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"REPORTS_SLA": "SLA",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SLA": "SLA",
"BETA": "Beta",
@@ -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,57 @@
<template>
<div class="flex flex-col flex-1 gap-6 p-4 overflow-auto font-inter">
<!-- <SLA-filters /> -->
<SLAMetrics
:hit-rate="slaMetrics.hitRate"
:no-of-breaches="slaMetrics.numberOfSLABreaches"
:is-loading="uiFlags.isLoading"
/>
<SLATable
:sla-reports="slaReports"
:current-page="Number(slaMeta.currentPage)"
:total-count="Number(slaMeta.count)"
@page-change="onPageChange"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import SLAMetrics from './components/SLA/SLAMetrics.vue';
import SLATable from './components/SLA/SLATable.vue';
// import SLAFilters from './components/SLA/SLAFilters.vue';
export default {
name: 'SLAReports',
components: {
SLAMetrics,
SLATable,
// SLAFilters,
},
data() {
return {
pageNumber: 1,
};
},
computed: {
...mapGetters({
slaReports: 'slaReports/getAll',
slaMetrics: 'slaReports/getMetrics',
slaMeta: 'slaReports/getMeta',
uiFlags: 'slaReports/getUIFlags',
}),
},
mounted() {
this.$store.dispatch('slaReports/getMetrics');
this.fetchSLAReports();
},
methods: {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
});
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
},
};
</script>
@@ -0,0 +1,55 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
}
</style>
@@ -0,0 +1,66 @@
<template>
<div
class="menu text-slate-700 dark:text-slate-200 font-medium text-sm h-11 flex items-center hover:bg-slate-50 dark:hover:bg-slate-100"
role="button"
@click.stop="$emit('click')"
>
<span
v-if="variant === 'label' && option.color"
class="label-pill"
:style="{ backgroundColor: option.color }"
/>
<thumbnail
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label overflow-hidden whitespace-nowrap text-ellipsis">
{{ option.label }}
</p>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
},
};
</script>
<style scoped lang="scss">
.menu {
width: calc(var(--space-mega) * 2);
@apply flex items-center flex-nowrap p-1 rounded-sm overflow-hidden cursor-pointer;
.menu-label {
@apply my-0 mx-2 text-xs flex-shrink-0;
}
// &:hover {
// @apply bg-woot-500 dark:bg-woot-500 text-white dark:text-slate-50;
// }
}
.agent-thumbnail {
margin-top: 0 !important;
}
.label-pill {
@apply w-4 h-4 rounded-full border border-slate-50 border-solid dark:border-slate-900 flex-shrink-0;
}
</style>
@@ -0,0 +1,122 @@
<template>
<div
class="bg-white dark:bg-slate- rounded-xl border-slate-100 dark:border-slate-900 border-solid border"
>
<menu-item
:option="unreadOption"
variant="icon"
@click="$emit('mark-as-unread')"
/>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
/>
</menu-item-with-submenu>
<template v-for="option in statusMenuConfig">
<menu-item
v-if="show(option.key)"
:key="option.key"
:option="option"
variant="icon"
/>
</template>
<menu-item
v-if="show(snoozeOption.key)"
:option="snoozeOption"
variant="icon"
/>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
/>
</menu-item-with-submenu>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
},
mixins: [agentMixin],
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
},
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
],
snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
},
priorityConfig: {
key: 'priority',
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
},
].filter(item => item.key !== this.priority),
},
};
},
computed: {
...mapGetters({}),
},
methods: {
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
},
};
</script>
@@ -0,0 +1,50 @@
<template>
<div>
<woot-button
color-scheme="secondary"
icon="plus-sign"
class="rounded-xl"
@click="openContextMenu"
>
Add filter
</woot-button>
<woot-context-menu
v-if="showContextMenu"
ref="menu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<SLA-context-menu />
</woot-context-menu>
</div>
</template>
<script>
import SLAContextMenu from './SLAContextMenu.vue';
export default {
name: 'SLAFilters',
components: {
SLAContextMenu,
},
props: {},
data() {
return {
showContextMenu: false,
};
},
computed: {},
methods: {
openContextMenu() {
this.showContextMenu = true;
this.contextMenu = {
x: null,
y: null,
};
},
closeContextMenu() {
this.showContextMenu = false;
},
},
};
</script>
@@ -0,0 +1,46 @@
<template>
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
<span
class="inline-flex items-center gap-1 text-sm text-slate-700 dark:text-slate-200 font-medium"
>
{{ label }}
<fluent-icon
v-tooltip.right="toolTip"
size="14"
icon="information"
type="outline"
class="flex-shrink-0 text-sm font-normal flex sm:font-medium text-slate-500 dark:text-slate-500"
/>
</span>
<div
v-if="isLoading"
class="w-12 h-6 mb-0.5 rounded-md bg-slate-50 animate-pulse"
/>
<span v-else class="text-2xl font-medium text-slate-900 dark:text-slate-25">
{{ value }}
</span>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
toolTip: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -0,0 +1,40 @@
<template>
<div
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 bg-white dark:bg-slate-900 rounded-xl border border-slate-75 dark:border-slate-700/50 px-6 py-4"
>
<SLAMetricCard
:label="$t('SLA_REPORTS.METRICS.HIT_RATE.LABEL')"
:value="hitRate"
:tool-tip="$t('SLA_REPORTS.METRICS.HIT_RATE.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_BREACHES.LABEL')"
:value="noOfBreaches"
:tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_BREACHES.TOOLTIP')"
:is-loading="isLoading"
/>
</div>
</template>
<script setup>
import SLAMetricCard from './SLAMetricCard.vue';
defineProps({
hitRate: {
type: String,
required: true,
},
noOfBreaches: {
type: Number,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
</script>
@@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import CardLabels from 'dashboard/components/widgets/conversation/conversationCardComponents/CardLabels.vue';
const props = defineProps({
slaName: {
type: String,
required: true,
},
conversationId: {
type: Number,
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const assignee = computed(() => props.conversation.assignee);
</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 border-slate-75 dark:border-slate-800/50 dark:bg-slate-900"
>
<div
class="flex items-center capitalize py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 text-left rtl:text-right col-span-2"
>
{{ slaName }}
</div>
<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-100">
{{ `#${conversationId} ` }}
</span>
<span class="text-slate-600 dark:text-slate-100">with </span>
<span class="text-slate-700 dark:text-slate-100 capitalize truncate">{{
conversation.contact.name
}}</span>
<card-labels
class="w-[80%]"
:conversation-id="conversationId"
:conversation-labels="conversation.labels"
/>
</div>
<div class="flex items-center gap-2 col-span-2">
<user-avatar-with-name v-if="assignee" :user="assignee" />
<span
v-else
class="text-slate-600 dark:text-slate-200 capitalize truncate"
>
---
</span>
</div>
<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"
>
<woot-button color-scheme="secondary" variant="link">
View details
</woot-button>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<template>
<div>
<div
class="min-w-full border-t border-x rounded-xl border-slate-75 dark:border-slate-800/50"
>
<!-- Header -->
<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"
>
<div
class="flex items-center font-medium text-xs py-2 px-0 tracking-[10%] text-slate-700 dark:text-slate-100 text-left rtl:text-right uppercase col-span-2"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.POLICY_BREACHED') }}
</div>
<div
class="flex items-center col-span-6 px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.CONVERSATION') }}
</div>
<div
class="flex items-center px-0 text-xs font-medium text-left uppercase col-span-2py-2 text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.AGENT') }}
</div>
<div
class="col-span-2 px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
/>
</div>
<div
v-show="!slaReports.length"
class="flex items-center justify-center h-32 bg-white dark:bg-slate-900"
>
{{ $t('SLA_REPORTS.NO_RECORDS') }}
</div>
<SLA-report-item
v-for="sla in slaReports"
:key="sla.id"
:sla-name="sla.sla_policy.name"
:conversation="sla.conversation"
:conversation-id="sla.conversation_id"
/>
</div>
<table-footer
v-if="shouldShowFooter"
:current-page="currentPage"
:total-count="totalCount"
:page-size="pageSize"
class="dark:bg-slate-900 sticky bottom-0 border-none"
@page-change="onPageChange"
/>
</div>
</template>
<script>
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import SLAReportItem from './SLAReportItem.vue';
export default {
name: 'SLATable',
components: {
SLAReportItem,
TableFooter,
},
props: {
slaReports: {
type: Array,
default: () => [],
},
totalCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
},
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,95 @@
<template>
<div
:style="{ height: size, width: size }"
class="rounded-full flex items-center justify-center bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
:title="userName"
>
<slot>
<img
v-if="shouldShowImage"
:src="src"
draggable="false"
@load="onImgLoad"
@error="onImgError"
/>
<div
v-else
:style="{ height: size, width: size }"
class="flex justify-center items-center rounded-full"
aria-hidden="true"
>
<span :style="{ fontSize: fontSize }" class="font-semibold">{{
userInitial
}}</span>
</div>
</slot>
</div>
</template>
<script>
import { removeEmoji } from 'shared/helpers/emoji';
export default {
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '24px',
},
userName: {
type: String,
default: '',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.userName);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
userInitial() {
return this.userName.charAt(0).toUpperCase();
},
fontSize() {
return `${Math.floor(this.avatarSize / 2.5)}px`;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
@@ -0,0 +1,43 @@
<template>
<div
class="text-slate-800 dark:text-slate-100 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-white dark:bg-slate-700 justify-between hover:bg-woot-75 cursor-pointer dark:hover:bg-slate-800"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
>
<div class="flex items-center">
<p class="my-0 mx-2 text-xs">{{ option.label }}</p>
</div>
<div
v-if="subMenuAvailable"
class="submenu bg-white dark:bg-slate-700 p-1 shadow-lg rounded-md absolute left-full top-0 hidden min-h-min max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
>
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
option: {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
},
};
</script>
<style scoped lang="scss">
.menu-with-submenu {
min-width: calc(6.25rem * 2);
&:hover {
.submenu {
@apply block;
}
}
}
</style>
@@ -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,
},
],
},
],
};
+2
View File
@@ -44,6 +44,7 @@ import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports';
import LogRocket from 'logrocket';
import createPlugin from 'logrocket-vuex';
@@ -111,6 +112,7 @@ export default new Vuex.Store({
webhooks,
draftMessages,
sla,
slaReports: SLAReports,
},
plugins,
});
@@ -0,0 +1,99 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import SLAReportsAPI from '../../api/slaReports';
export const state = {
records: [],
metrics: {
numberOfSLABreaches: 0,
hitRate: '0%',
},
uiFlags: {
isFetching: false,
isFetchingMetrics: false,
},
meta: {
count: 0,
currentPage: 1,
},
};
export const getters = {
getAll(_state) {
return _state.records;
},
getMeta(_state) {
return _state.meta;
},
getMetrics(_state) {
return _state.metrics;
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
get: async function getResponses({ commit }, params) {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true });
try {
const response = await SLAReportsAPI.get(params);
const { payload, meta } = response.data;
commit(types.SET_SLA_REPORTS, payload);
commit(types.SET_SLA_REPORTS_META, meta);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false });
}
},
getMetrics: async function getMetrics({ commit }, params) {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true });
try {
const response = await SLAReportsAPI.getMetrics(params);
commit(types.SET_SLA_REPORTS_METRICS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false });
}
},
};
export const mutations = {
[types.SET_SLA_REPORTS_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_SLA_REPORTS]: MutationHelpers.set,
[types.SET_SLA_REPORTS_METRICS](
_state,
{ number_of_sla_breaches: numberOfSLABreaches, hit_rate: hitRate }
) {
_state.metrics = {
numberOfSLABreaches,
hitRate,
};
},
[types.SET_SLA_REPORTS_META](
_state,
{ total_applied_slas: totalAppliedSLAs, current_page: currentPage }
) {
_state.meta = {
count: totalAppliedSLAs,
currentPage,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -309,4 +309,10 @@ export default {
ADD_SLA: 'ADD_SLA',
EDIT_SLA: 'EDIT_SLA',
DELETE_SLA: 'DELETE_SLA',
// SLA Reports
SET_SLA_REPORTS_UI_FLAG: 'SET_SLA_REPORTS_UI_FLAG',
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
};
+5
View File
@@ -33,6 +33,11 @@ class NotificationListener < BaseListener
return if event.data[:notifiable_assignee_change].blank?
return if conversation.pending?
if assignee.nil?
Rail.logger.error "[NotificationListener] Assignee is nil, skipping notification creation for #{conversation.id}"
return
end
NotificationBuilder.new(
notification_type: 'conversation_assignment',
user: assignee,
@@ -6,12 +6,14 @@ 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
@total_applied_slas = total_applied_slas
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def metrics
@total_applied_slas = total_applied_slas
@@ -52,7 +54,7 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
end
def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation)
initial_query = Current.account.applied_slas.includes(:conversation).includes(:sla_policy)
@applied_slas = initial_query
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
@@ -62,10 +64,6 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end
def paginate_slas
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def set_current_page
@current_page = params[:page] || 1
end
+1 -1
View File
@@ -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]) }
private
def ensure_account_id
@@ -1,14 +1,28 @@
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.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.contact do
json.name applied_sla.conversation.contact.name
end
json.labels applied_sla.conversation.cached_label_list
json.assignee applied_sla.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
json.sla_policy do
json.partial! 'api/v1/models/sla_policy', sla_policy: applied_sla.sla_policy
end
end
end
json.meta do
json.total_applied_slas @total_applied_slas
json.current_page @current_page
end