Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7847e3f69 | ||
|
|
4a63b54630 | ||
|
|
401babb844 | ||
|
|
3500b1a487 | ||
|
|
2cf8856b62 | ||
|
|
698642fecb | ||
|
|
b235d66f81 | ||
|
|
2545e26e8f | ||
|
|
6386142348 |
@@ -0,0 +1,58 @@
|
|||||||
|
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
|
||||||
|
before_action :set_group_scope, only: [:grouped_conversation_metrics]
|
||||||
|
|
||||||
|
def conversation_metrics
|
||||||
|
render json: {
|
||||||
|
open: @conversations.open.count,
|
||||||
|
unattended: @conversations.open.unattended.count,
|
||||||
|
unassigned: @conversations.open.unassigned.count,
|
||||||
|
pending: @conversations.pending.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def grouped_conversation_metrics
|
||||||
|
count_by_group = @conversations.open.group(@group_scope).count
|
||||||
|
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
|
||||||
|
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
|
||||||
|
|
||||||
|
group_metrics = count_by_group.map do |group_id, count|
|
||||||
|
metric = {
|
||||||
|
open: count,
|
||||||
|
unattended: unattended_by_group[group_id] || 0,
|
||||||
|
unassigned: unassigned_by_group[group_id] || 0
|
||||||
|
}
|
||||||
|
metric[@group_scope] = group_id
|
||||||
|
metric
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: group_metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_group_scope
|
||||||
|
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
|
||||||
|
team_id
|
||||||
|
assignee_id
|
||||||
|
].include?(permitted_params[:group_by])
|
||||||
|
|
||||||
|
@group_scope = permitted_params[:group_by]
|
||||||
|
end
|
||||||
|
|
||||||
|
def team
|
||||||
|
return unless permitted_params[:team_id]
|
||||||
|
|
||||||
|
@team ||= Current.account.teams.find(permitted_params[:team_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_conversations
|
||||||
|
scope = Current.account.conversations
|
||||||
|
scope = scope.where(team_id: team.id) if team.present?
|
||||||
|
@conversations = scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:team_id, :group_by)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def since_timestamp(date)
|
def since_timestamp(date)
|
||||||
(date - 6.days).to_i.to_s
|
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
|
||||||
|
(date - number_of_days).to_i.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def until_timestamp(date)
|
def until_timestamp(date)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class LiveReportsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getConversationMetric(params = {}) {
|
||||||
|
return axios.get(`${this.url}/conversation_metrics`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
|
||||||
|
return axios.get(`${this.url}/grouped_conversation_metrics`, {
|
||||||
|
params: { group_by: groupBy },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LiveReportsAPI();
|
||||||
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getConversationTrafficCSV() {
|
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||||
return axios.get(`${this.url}/conversation_traffic`, {
|
return axios.get(`${this.url}/conversation_traffic`, {
|
||||||
params: { timezone_offset: getTimeOffset() },
|
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
labelClass: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
const emit = defineEmits(['action']);
|
||||||
@@ -97,9 +101,13 @@ onMounted(() => {
|
|||||||
</slot>
|
</slot>
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
<span
|
||||||
item.label
|
v-if="item.label"
|
||||||
}}</span>
|
class="min-w-0 text-sm truncate"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="filteredMenuItems.length === 0"
|
v-if="filteredMenuItems.length === 0"
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
|
||||||
const showV4Routes = computed(() => {
|
const showV4Routes = computed(() => {
|
||||||
return isFeatureEnabledonAccount.value(
|
return isFeatureEnabledonAccount.value(
|
||||||
currentAccountId.value,
|
currentAccountId.value,
|
||||||
@@ -525,7 +527,12 @@ const menuItems = computed(() => {
|
|||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||||
<Logo />
|
<img
|
||||||
|
v-if="globalConfig.logoThumbnail"
|
||||||
|
:src="globalConfig.logoThumbnail"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<Logo v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||||
<SidebarAccountSwitcher
|
<SidebarAccountSwitcher
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ref, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
export const useLiveRefresh = (callback, interval = 60000) => {
|
||||||
|
const timeoutId = ref(null);
|
||||||
|
|
||||||
|
const startRefetching = () => {
|
||||||
|
timeoutId.value = setTimeout(async () => {
|
||||||
|
await callback();
|
||||||
|
startRefetching();
|
||||||
|
}, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRefetching = () => {
|
||||||
|
if (timeoutId.value) {
|
||||||
|
clearTimeout(timeoutId.value);
|
||||||
|
timeoutId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopRefetching();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
startRefetching,
|
||||||
|
stopRefetching,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -476,6 +476,18 @@
|
|||||||
"STATUS": "Status"
|
"STATUS": "Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"TEAM_CONVERSATIONS": {
|
||||||
|
"ALL_TEAMS": "All Teams",
|
||||||
|
"HEADER": "Conversations by teams",
|
||||||
|
"LOADING_MESSAGE": "Loading team metrics...",
|
||||||
|
"NO_TEAMS": "There is no data available",
|
||||||
|
"TABLE_HEADER": {
|
||||||
|
"TEAM": "Team",
|
||||||
|
"OPEN": "Open",
|
||||||
|
"UNATTENDED": "Unattended",
|
||||||
|
"STATUS": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"AGENT_STATUS": {
|
"AGENT_STATUS": {
|
||||||
"HEADER": "Agent status",
|
"HEADER": "Agent status",
|
||||||
"ONLINE": "Online",
|
"ONLINE": "Online",
|
||||||
|
|||||||
@@ -1,214 +1,17 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import AgentTable from './components/overview/AgentTable.vue';
|
|
||||||
import MetricCard from './components/overview/MetricCard.vue';
|
|
||||||
import { OVERVIEW_METRICS } from './constants';
|
|
||||||
import ReportHeatmap from './components/Heatmap.vue';
|
|
||||||
|
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
|
||||||
import subDays from 'date-fns/subDays';
|
|
||||||
import ReportHeader from './components/ReportHeader.vue';
|
import ReportHeader from './components/ReportHeader.vue';
|
||||||
export const FETCH_INTERVAL = 60000;
|
import HeatmapContainer from './components/HeatmapContainer.vue';
|
||||||
|
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
|
||||||
export default {
|
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
|
||||||
name: 'LiveReports',
|
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
|
||||||
components: {
|
|
||||||
ReportHeader,
|
|
||||||
AgentTable,
|
|
||||||
MetricCard,
|
|
||||||
ReportHeatmap,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
// always start with 0, this is to manage the pagination in tanstack table
|
|
||||||
// when we send the data, we do a +1 to this value
|
|
||||||
pageIndex: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
agentStatus: 'agents/getAgentStatus',
|
|
||||||
agents: 'agents/getAgents',
|
|
||||||
accountConversationMetric: 'getAccountConversationMetric',
|
|
||||||
agentConversationMetric: 'getAgentConversationMetric',
|
|
||||||
accountConversationHeatmap: 'getAccountConversationHeatmapData',
|
|
||||||
uiFlags: 'getOverviewUIFlags',
|
|
||||||
}),
|
|
||||||
agentStatusMetrics() {
|
|
||||||
let metric = {};
|
|
||||||
Object.keys(this.agentStatus).forEach(key => {
|
|
||||||
const metricName = this.$t(
|
|
||||||
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
|
|
||||||
);
|
|
||||||
metric[metricName] = this.agentStatus[key];
|
|
||||||
});
|
|
||||||
return metric;
|
|
||||||
},
|
|
||||||
conversationMetrics() {
|
|
||||||
let metric = {};
|
|
||||||
Object.keys(this.accountConversationMetric).forEach(key => {
|
|
||||||
const metricName = this.$t(
|
|
||||||
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
|
|
||||||
);
|
|
||||||
metric[metricName] = this.accountConversationMetric[key];
|
|
||||||
});
|
|
||||||
return metric;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch('agents/get');
|
|
||||||
this.initalizeReport();
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.timeoutId) {
|
|
||||||
clearTimeout(this.timeoutId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
initalizeReport() {
|
|
||||||
this.fetchAllData();
|
|
||||||
this.scheduleReportRefresh();
|
|
||||||
},
|
|
||||||
scheduleReportRefresh() {
|
|
||||||
this.timeoutId = setTimeout(async () => {
|
|
||||||
await this.fetchAllData();
|
|
||||||
this.scheduleReportRefresh();
|
|
||||||
}, FETCH_INTERVAL);
|
|
||||||
},
|
|
||||||
fetchAllData() {
|
|
||||||
this.fetchAccountConversationMetric();
|
|
||||||
this.fetchAgentConversationMetric();
|
|
||||||
this.fetchHeatmapData();
|
|
||||||
},
|
|
||||||
downloadHeatmapData() {
|
|
||||||
let to = endOfDay(new Date());
|
|
||||||
|
|
||||||
this.$store.dispatch('downloadAccountConversationHeatmap', {
|
|
||||||
to: getUnixTime(to),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchHeatmapData() {
|
|
||||||
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the data for the last 6 days won't ever change,
|
|
||||||
// so there's no need to fetch it again
|
|
||||||
// but we can write some logic to check if the data is already there
|
|
||||||
// if it is there, we can refetch data only for today all over again
|
|
||||||
// and reconcile it with the rest of the data
|
|
||||||
// this will reduce the load on the server doing number crunching
|
|
||||||
let to = endOfDay(new Date());
|
|
||||||
let from = startOfDay(subDays(to, 6));
|
|
||||||
|
|
||||||
if (this.accountConversationHeatmap.length) {
|
|
||||||
to = endOfDay(new Date());
|
|
||||||
from = startOfDay(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$store.dispatch('fetchAccountConversationHeatmap', {
|
|
||||||
metric: 'conversations_count',
|
|
||||||
from: getUnixTime(from),
|
|
||||||
to: getUnixTime(to),
|
|
||||||
groupBy: 'hour',
|
|
||||||
businessHours: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchAccountConversationMetric() {
|
|
||||||
this.$store.dispatch('fetchAccountConversationMetric', {
|
|
||||||
type: 'account',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchAgentConversationMetric() {
|
|
||||||
this.$store.dispatch('fetchAgentConversationMetric', {
|
|
||||||
type: 'agent',
|
|
||||||
page: this.pageIndex + 1,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPageNumberChange(pageIndex) {
|
|
||||||
this.pageIndex = pageIndex;
|
|
||||||
this.fetchAgentConversationMetric();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||||
<div class="flex flex-col gap-4 pb-6">
|
<div class="flex flex-col gap-4 pb-6">
|
||||||
<div class="flex flex-col items-center md:flex-row gap-4">
|
<StatsLiveReportsContainer />
|
||||||
<div
|
<HeatmapContainer />
|
||||||
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
<AgentLiveReportContainer />
|
||||||
>
|
<TeamLiveReportContainer />
|
||||||
<MetricCard
|
|
||||||
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationMetric"
|
|
||||||
:loading-message="
|
|
||||||
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(metric, name, index) in conversationMetrics"
|
|
||||||
:key="index"
|
|
||||||
class="flex-1 min-w-0 pb-2"
|
|
||||||
>
|
|
||||||
<h3 class="text-base text-n-slate-11">
|
|
||||||
{{ name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
|
||||||
{{ metric }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
|
|
||||||
<div
|
|
||||||
v-for="(metric, name, index) in agentStatusMetrics"
|
|
||||||
:key="index"
|
|
||||||
class="flex-1 min-w-0 pb-2"
|
|
||||||
>
|
|
||||||
<h3 class="text-base text-n-slate-11">
|
|
||||||
{{ name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
|
||||||
{{ metric }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
|
||||||
<template #control>
|
|
||||||
<woot-button
|
|
||||||
icon="arrow-download"
|
|
||||||
size="small"
|
|
||||||
variant="smooth"
|
|
||||||
color-scheme="secondary"
|
|
||||||
@click="downloadHeatmapData"
|
|
||||||
>
|
|
||||||
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
|
|
||||||
</woot-button>
|
|
||||||
</template>
|
|
||||||
<ReportHeatmap
|
|
||||||
:heat-data="accountConversationHeatmap"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
|
||||||
<AgentTable
|
|
||||||
:agents="agents"
|
|
||||||
:agent-metrics="agentConversationMetric"
|
|
||||||
:page-index="pageIndex"
|
|
||||||
:is-loading="uiFlags.isFetchingAgentConversationMetric"
|
|
||||||
@page-change="onPageNumberChange"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import AgentTable from './overview/AgentTable.vue';
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const agentConversationMetric = useMapGetter('getAgentConversationMetric');
|
||||||
|
const agents = useMapGetter('agents/getAgents');
|
||||||
|
|
||||||
|
const fetchData = () => store.dispatch('fetchAgentConversationMetric');
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('agents/get');
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||||
|
<AgentTable
|
||||||
|
:agents="agents"
|
||||||
|
:agent-metrics="agentConversationMetric"
|
||||||
|
:is-loading="uiFlags.isFetchingAgentConversationMetric"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
heatData: {
|
heatmapData: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
numberOfRows: {
|
||||||
|
type: Number,
|
||||||
|
default: 7,
|
||||||
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -21,11 +25,11 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const processedData = computed(() => {
|
const processedData = computed(() => {
|
||||||
return groupHeatmapByDay(props.heatData);
|
return groupHeatmapByDay(props.heatmapData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const quantileRange = computed(() => {
|
const quantileRange = computed(() => {
|
||||||
const flattendedData = props.heatData.map(data => data.value);
|
const flattendedData = props.heatmapData.map(data => data.value);
|
||||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
|
|||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
>
|
>
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import ReportHeatmap from './Heatmap.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import subDays from 'date-fns/subDays';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const accountConversationHeatmap = useMapGetter(
|
||||||
|
'getAccountConversationHeatmapData'
|
||||||
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||||
|
value: 29,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedDays = ref(6);
|
||||||
|
|
||||||
|
const selectedDayFilter = computed(() =>
|
||||||
|
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadHeatmapData = () => {
|
||||||
|
const to = endOfDay(new Date());
|
||||||
|
store.dispatch('downloadAccountConversationHeatmap', {
|
||||||
|
daysBefore: selectedDays.value,
|
||||||
|
to: getUnixTime(to),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const fetchHeatmapData = () => {
|
||||||
|
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = endOfDay(new Date());
|
||||||
|
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||||
|
|
||||||
|
store.dispatch('fetchAccountConversationHeatmap', {
|
||||||
|
metric: 'conversations_count',
|
||||||
|
from: getUnixTime(from),
|
||||||
|
to: getUnixTime(to),
|
||||||
|
groupBy: 'hour',
|
||||||
|
businessHours: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedDays.value = value;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHeatmapData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||||
|
<template #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedDayFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="downloadHeatmapData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ReportHeatmap
|
||||||
|
:heatmap-data="accountConversationHeatmap"
|
||||||
|
:number-of-rows="selectedDays + 1"
|
||||||
|
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { OVERVIEW_METRICS } from '../constants';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const agentStatus = useMapGetter('agents/getAgentStatus');
|
||||||
|
const accountConversationMetric = useMapGetter('getAccountConversationMetric');
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const accounti18nKey = 'OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS';
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
|
||||||
|
const teamMenuList = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.ALL_TEAMS'), value: null },
|
||||||
|
...teams.value.map(team => ({ label: team.name, value: team.id })),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentStatusMetrics = computed(() => {
|
||||||
|
let metric = {};
|
||||||
|
Object.keys(agentStatus.value).forEach(key => {
|
||||||
|
const metricName = t(
|
||||||
|
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
|
||||||
|
);
|
||||||
|
metric[metricName] = agentStatus.value[key];
|
||||||
|
});
|
||||||
|
return metric;
|
||||||
|
});
|
||||||
|
const conversationMetrics = computed(() => {
|
||||||
|
let metric = {};
|
||||||
|
Object.keys(accountConversationMetric.value).forEach(key => {
|
||||||
|
const metricName = t(`${accounti18nKey}.${OVERVIEW_METRICS[key]}`);
|
||||||
|
metric[metricName] = accountConversationMetric.value[key];
|
||||||
|
});
|
||||||
|
return metric;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTeam = ref(null);
|
||||||
|
const selectedTeamLabel = computed(() => {
|
||||||
|
const team =
|
||||||
|
teamMenuList.value.find(
|
||||||
|
menuItem => menuItem.value === selectedTeam.value
|
||||||
|
) || {};
|
||||||
|
return team.label;
|
||||||
|
});
|
||||||
|
const fetchData = () => {
|
||||||
|
const params = {};
|
||||||
|
if (selectedTeam.value) {
|
||||||
|
params.team_id = selectedTeam.value;
|
||||||
|
}
|
||||||
|
store.dispatch('fetchAccountConversationMetric', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedTeam.value = value;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center md:flex-row gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
||||||
|
>
|
||||||
|
<MetricCard
|
||||||
|
:header="t(`${accounti18nKey}.HEADER`)"
|
||||||
|
:is-loading="uiFlags.isFetchingAccountConversationMetric"
|
||||||
|
:loading-message="t(`${accounti18nKey}.LOADING_MESSAGE`)"
|
||||||
|
>
|
||||||
|
<template v-if="teams.length" #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group z-50"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedTeamLabel"
|
||||||
|
class="capitalize rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="teamMenuList"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
label-class="capitalize"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-for="(metric, name, index) in conversationMetrics"
|
||||||
|
:key="index"
|
||||||
|
class="flex-1 min-w-0 pb-2"
|
||||||
|
>
|
||||||
|
<h3 class="text-base text-n-slate-11">
|
||||||
|
{{ name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||||
|
{{ metric }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
|
||||||
|
<div
|
||||||
|
v-for="(metric, name, index) in agentStatusMetrics"
|
||||||
|
:key="index"
|
||||||
|
class="flex-1 min-w-0 pb-2"
|
||||||
|
>
|
||||||
|
<h3 class="text-base text-n-slate-11">
|
||||||
|
{{ name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||||
|
{{ metric }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import TeamTable from './overview/TeamTable.vue';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const teamConversationMetric = useMapGetter('getTeamConversationMetric');
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
|
||||||
|
const fetchData = () => store.dispatch('fetchTeamConversationMetric');
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('teams/get');
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
|
||||||
|
<TeamTable
|
||||||
|
:teams="teams"
|
||||||
|
:team-metrics="teamConversationMetric"
|
||||||
|
:is-loading="uiFlags.isFetchingTeamConversationMetric"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+38
-101
@@ -4,6 +4,7 @@ import {
|
|||||||
useVueTable,
|
useVueTable,
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
} from '@tanstack/vue-table';
|
} from '@tanstack/vue-table';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import Table from 'dashboard/components/table/Table.vue';
|
|||||||
import Pagination from 'dashboard/components/table/Pagination.vue';
|
import Pagination from 'dashboard/components/table/Pagination.vue';
|
||||||
import AgentCell from './AgentCell.vue';
|
import AgentCell from './AgentCell.vue';
|
||||||
|
|
||||||
const { agents, agentMetrics, pageIndex } = defineProps({
|
const { agents, agentMetrics } = defineProps({
|
||||||
agents: {
|
agents: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
pageIndex: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['pageChange']);
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
function getAgentInformation(id) {
|
const getAgentMetrics = id =>
|
||||||
return agents?.find(agent => agent.id === Number(id));
|
agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {};
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = computed(() => agents.length);
|
const tableData = computed(() =>
|
||||||
|
agents
|
||||||
const tableData = computed(() => {
|
|
||||||
return agentMetrics
|
|
||||||
.filter(agentMetric => getAgentInformation(agentMetric.id))
|
|
||||||
.map(agent => {
|
.map(agent => {
|
||||||
const agentInformation = getAgentInformation(agent.id);
|
const metric = getAgentMetrics(agent.id);
|
||||||
return {
|
return {
|
||||||
agent: agentInformation.name || agentInformation.available_name,
|
agent: agent.available_name || agent.name,
|
||||||
email: agentInformation.email,
|
email: agent.email,
|
||||||
thumbnail: agentInformation.thumbnail,
|
thumbnail: agent.thumbnail,
|
||||||
open: agent.metric.open ?? 0,
|
open: metric.open || 0,
|
||||||
unattended: agent.metric.unattended ?? 0,
|
unattended: metric.unattended || 0,
|
||||||
status: agentInformation.availability_status,
|
status: agent.availability_status,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
});
|
.sort((a, b) => {
|
||||||
|
// First sort by open tickets (descending)
|
||||||
|
const openDiff = b.open - a.open;
|
||||||
|
// If open tickets are equal, sort by name (ascending)
|
||||||
|
if (openDiff === 0) {
|
||||||
|
return a.agent.localeCompare(b.agent);
|
||||||
|
}
|
||||||
|
return openDiff;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const defaulSpanRender = cellProps =>
|
const defaulSpanRender = cellProps =>
|
||||||
h(
|
h(
|
||||||
'span',
|
'span',
|
||||||
|
|
||||||
{
|
{
|
||||||
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
|
class: cellProps.getValue()
|
||||||
|
? 'capitalize text-n-slate-12'
|
||||||
|
: 'capitalize text-n-slate-11',
|
||||||
},
|
},
|
||||||
cellProps.getValue() ? cellProps.getValue() : '---'
|
cellProps.getValue() ? cellProps.getValue() : '---'
|
||||||
);
|
);
|
||||||
@@ -86,100 +90,33 @@ const columns = [
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const paginationParams = computed(() => {
|
|
||||||
return {
|
|
||||||
pageIndex: pageIndex,
|
|
||||||
pageSize: 25,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useVueTable({
|
const table = useVueTable({
|
||||||
get data() {
|
get data() {
|
||||||
return tableData.value;
|
return tableData.value;
|
||||||
},
|
},
|
||||||
columns,
|
columns,
|
||||||
manualPagination: true,
|
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
get rowCount() {
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
return totalCount.value;
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
get pagination() {
|
|
||||||
return paginationParams.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onPaginationChange: updater => {
|
|
||||||
const newPagintaion = updater(paginationParams.value);
|
|
||||||
emit('pageChange', newPagintaion.pageIndex);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="agent-table-container">
|
<div class="flex flex-col flex-1">
|
||||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||||
<Pagination class="mt-2" :table="table" />
|
<Pagination class="mt-2" :table="table" />
|
||||||
<div v-if="isLoading" class="agents-loader">
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="items-center flex text-base justify-center p-8"
|
||||||
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span>{{
|
<span>
|
||||||
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
|
{{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }}
|
||||||
}}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="!isLoading && !agentMetrics.length"
|
v-else-if="!isLoading && !agents.length"
|
||||||
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
|
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.agent-table-container {
|
|
||||||
@apply flex flex-col flex-1;
|
|
||||||
|
|
||||||
.ve-table {
|
|
||||||
&::v-deep {
|
|
||||||
th.ve-table-header-th {
|
|
||||||
@apply text-sm rounded-xl;
|
|
||||||
padding: var(--space-small) var(--space-two) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.ve-table-body-td {
|
|
||||||
padding: var(--space-one) var(--space-two) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::v-deep .ve-pagination {
|
|
||||||
@apply bg-transparent dark:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::v-deep .ve-pagination-select {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-user-block {
|
|
||||||
@apply items-center flex text-left;
|
|
||||||
|
|
||||||
.user-block {
|
|
||||||
@apply items-start flex flex-col min-w-0 my-0 mx-2;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-title {
|
|
||||||
@apply text-xs text-slate-600 dark:text-slate-200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-pagination {
|
|
||||||
@apply mt-4 text-right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-loader {
|
|
||||||
@apply items-center flex text-base justify-center p-8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+5
-13
@@ -1,12 +1,7 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'MetricCard',
|
|
||||||
components: {
|
|
||||||
Spinner,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
header: {
|
header: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
@@ -19,13 +14,12 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
|
class="flex flex-col m-0.5 px-6 py-5 rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
|
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
|
||||||
@@ -46,9 +40,7 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-row items-center justify-end gap-2">
|
||||||
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
|
|
||||||
>
|
|
||||||
<slot name="control" />
|
<slot name="control" />
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, h } from 'vue';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
createColumnHelper,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||||
|
import Table from 'dashboard/components/table/Table.vue';
|
||||||
|
import Pagination from 'dashboard/components/table/Pagination.vue';
|
||||||
|
|
||||||
|
const { teams, teamMetrics } = defineProps({
|
||||||
|
teams: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
teamMetrics: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const getTeamMetrics = id =>
|
||||||
|
teamMetrics.find(metrics => metrics.team_id === Number(id)) || {};
|
||||||
|
|
||||||
|
const tableData = computed(() =>
|
||||||
|
teams
|
||||||
|
.map(team => {
|
||||||
|
const metric = getTeamMetrics(team.id);
|
||||||
|
return {
|
||||||
|
agent: team.name,
|
||||||
|
open: metric.open || 0,
|
||||||
|
unattended: metric.unattended || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// First sort by open tickets (descending)
|
||||||
|
const openDiff = b.open - a.open;
|
||||||
|
// If open tickets are equal, sort by name (ascending)
|
||||||
|
if (openDiff === 0) {
|
||||||
|
return a.agent.localeCompare(b.agent);
|
||||||
|
}
|
||||||
|
return openDiff;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaulSpanRender = cellProps =>
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: cellProps.getValue()
|
||||||
|
? 'capitalize text-n-slate-12'
|
||||||
|
: 'capitalize text-n-slate-11',
|
||||||
|
},
|
||||||
|
cellProps.getValue() ? cellProps.getValue() : '---'
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper();
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('agent', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.TEAM'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 250,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('open', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('unattended', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return tableData.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
enableSorting: false,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||||
|
<Pagination class="mt-2" :table="table" />
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="items-center flex text-base justify-center p-8"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
<span>
|
||||||
|
{{ $t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="!isLoading && !teams.length"
|
||||||
|
:title="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.NO_TEAMS')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,10 +4,8 @@ import Report from '../../api/reports';
|
|||||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
import {
|
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
|
||||||
reconcileHeatmapData,
|
import liveReports from '../../api/liveReports';
|
||||||
clampDataBetweenTimeline,
|
|
||||||
} from 'shared/helpers/ReportsDataHelper';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
fetchingStatus: false,
|
fetchingStatus: false,
|
||||||
@@ -57,10 +55,12 @@ const state = {
|
|||||||
isFetchingAccountConversationMetric: false,
|
isFetchingAccountConversationMetric: false,
|
||||||
isFetchingAccountConversationsHeatmap: false,
|
isFetchingAccountConversationsHeatmap: false,
|
||||||
isFetchingAgentConversationMetric: false,
|
isFetchingAgentConversationMetric: false,
|
||||||
|
isFetchingTeamConversationMetric: false,
|
||||||
},
|
},
|
||||||
accountConversationMetric: {},
|
accountConversationMetric: {},
|
||||||
accountConversationHeatmap: [],
|
accountConversationHeatmap: [],
|
||||||
agentConversationMetric: [],
|
agentConversationMetric: [],
|
||||||
|
teamConversationMetric: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ const getters = {
|
|||||||
getAgentConversationMetric(_state) {
|
getAgentConversationMetric(_state) {
|
||||||
return _state.overview.agentConversationMetric;
|
return _state.overview.agentConversationMetric;
|
||||||
},
|
},
|
||||||
|
getTeamConversationMetric(_state) {
|
||||||
|
return _state.overview.teamConversationMetric;
|
||||||
|
},
|
||||||
getOverviewUIFlags($state) {
|
getOverviewUIFlags($state) {
|
||||||
return $state.overview.uiFlags;
|
return $state.overview.uiFlags;
|
||||||
},
|
},
|
||||||
@@ -114,11 +117,6 @@ export const actions = {
|
|||||||
let { data } = heatmapData;
|
let { data } = heatmapData;
|
||||||
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||||
|
|
||||||
data = reconcileHeatmapData(
|
|
||||||
data,
|
|
||||||
state.overview.accountConversationHeatmap
|
|
||||||
);
|
|
||||||
|
|
||||||
commit(types.default.SET_HEATMAP_DATA, data);
|
commit(types.default.SET_HEATMAP_DATA, data);
|
||||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||||
});
|
});
|
||||||
@@ -153,9 +151,10 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAccountConversationMetric({ commit }, reportObj) {
|
fetchAccountConversationMetric({ commit }, params = {}) {
|
||||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
||||||
Report.getConversationMetric(reportObj.type)
|
liveReports
|
||||||
|
.getConversationMetric(params)
|
||||||
.then(accountConversationMetric => {
|
.then(accountConversationMetric => {
|
||||||
commit(
|
commit(
|
||||||
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
|
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
|
||||||
@@ -167,9 +166,10 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
|
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAgentConversationMetric({ commit }, reportObj) {
|
fetchAgentConversationMetric({ commit }) {
|
||||||
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
|
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
|
||||||
Report.getConversationMetric(reportObj.type, reportObj.page)
|
liveReports
|
||||||
|
.getGroupedConversations({ groupBy: 'assignee_id' })
|
||||||
.then(agentConversationMetric => {
|
.then(agentConversationMetric => {
|
||||||
commit(
|
commit(
|
||||||
types.default.SET_AGENT_CONVERSATION_METRIC,
|
types.default.SET_AGENT_CONVERSATION_METRIC,
|
||||||
@@ -181,6 +181,18 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
|
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
fetchTeamConversationMetric({ commit }) {
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
|
||||||
|
liveReports
|
||||||
|
.getGroupedConversations({ groupBy: 'team_id' })
|
||||||
|
.then(teamMetric => {
|
||||||
|
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
|
||||||
|
});
|
||||||
|
},
|
||||||
downloadAgentReports(_, reportObj) {
|
downloadAgentReports(_, reportObj) {
|
||||||
return Report.getAgentReports(reportObj)
|
return Report.getAgentReports(reportObj)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -234,7 +246,7 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
downloadAccountConversationHeatmap(_, reportObj) {
|
downloadAccountConversationHeatmap(_, reportObj) {
|
||||||
Report.getConversationTrafficCSV()
|
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
downloadCsvFile(
|
downloadCsvFile(
|
||||||
generateFileName({
|
generateFileName({
|
||||||
@@ -286,6 +298,12 @@ const mutations = {
|
|||||||
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
|
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||||
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
|
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
|
||||||
},
|
},
|
||||||
|
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
|
||||||
|
_state.overview.teamConversationMetric = metricData;
|
||||||
|
},
|
||||||
|
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||||
|
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -335,4 +335,8 @@ export default {
|
|||||||
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
|
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
|
||||||
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
|
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
|
||||||
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
|
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
|
||||||
|
|
||||||
|
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
|
||||||
|
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
|
||||||
|
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.agent_csv.agent_name'),
|
I18n.t('reports.agent_csv.agent_name'),
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
I18n.t('reports.agent_csv.resolution_count')
|
I18n.t('reports.agent_csv.resolution_count')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
||||||
|
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.inbox_csv.inbox_name'),
|
I18n.t('reports.inbox_csv.inbox_name'),
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
I18n.t('reports.inbox_csv.avg_resolution_time')
|
I18n.t('reports.inbox_csv.avg_resolution_time')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.label_csv.label_title'),
|
I18n.t('reports.label_csv.label_title'),
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
I18n.t('reports.label_csv.avg_resolution_time')
|
I18n.t('reports.label_csv.avg_resolution_time')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.team_csv.team_name'),
|
I18n.t('reports.team_csv.team_name'),
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
I18n.t('reports.team_csv.resolution_count')
|
I18n.t('reports.team_csv.resolution_count')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -336,6 +336,12 @@ Rails.application.routes.draw do
|
|||||||
get :bot_metrics
|
get :bot_metrics
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :live_reports, only: [] do
|
||||||
|
collection do
|
||||||
|
get :conversation_metrics
|
||||||
|
get :grouped_conversation_metrics
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user