1<template>
2 <div class="flex flex-col rounded-5 p-4 grow w-full h-full overflow-hidden">
3 <div class="flex items-center justify-between">
4 <div class="text-md-semibold text-ink-gray-8">
5 {{ __("Avg. Time Metrics") }}
6 </div>
7 <div class="flex items-center gap-2">
8 <Dropdown
9 v-if="currentDuration !== 'custom_range'"
10 :options="durationOptions"
11 align="end"
12 >
13 <template #default>
14 <Button
15 :label="currentDurationLabel"
16 icon-right="lucide-chevron-down"
17 />
18 </template>
19 <template #item-label="{ item }">
20 <span>
21 {{ item.label }}
22 </span>
23 </template>
24
25 <template #item-suffix="{ item }">
26 <LucideCheck
27 v-if="item.label == durationLabels[currentDuration]"
28 class="size-4"
29 />
30 </template>
31 </Dropdown>
32 <DateRangePicker
33 v-else
34 ref="datePickerRef"
35 :model-value="customDateRange ? customDateRange.split(',') : []"
36 :placeholder="__('Select range')"
37 @update:model-value="onCustomRangeSelected"
38 @update:open="onPickerToggle"
39 :format="'MMM D'"
40 side="bottom"
41 align="end"
42 class="!w-48"
43 />
44 </div>
45 </div>
46 <div
47 v-if="
48 timeAverages.first_response == '0m' && timeAverages.resolution == '0m'
49 "
50 class="relative flex flex-col mt-5 grow w-full select-none"
51 >
52 <div class="flex items-center gap-12">
53 <div>
54 <div
55 class="text-md-medium text-ink-gray-8 w-20 rounded-1 h-4 bg-surface-gray-1"
56 />
57 <div
58 class="w-40 rounded-1 h-4 bg-surface-gray-1 text-base flex items-center gap-2 mt-1"
59 />
60 </div>
61 <div>
62 <div
63 class="text-md-medium text-ink-gray-8 w-20 rounded-1 h-4 bg-surface-gray-1"
64 />
65 <div
66 class="w-40 rounded-1 h-4 bg-surface-gray-1 text-base flex items-center gap-2 mt-1"
67 />
68 </div>
69 </div>
70 <div class="w-full grow pointer-events-none mt-6 relative flex flex-col">
71 <div class="flex-1 relative flex items-end justify-around px-4">
72 <div class="absolute inset-0 flex flex-col justify-between z-0">
73 <div
74 v-for="idx in 5"
75 :key="idx"
76 class="border-t border-dashed border-outline-gray-2 w-full"
77 />
78 </div>
79 <div
80 v-for="idx in 6"
81 :key="idx"
82 class="relative z-10 flex gap-2 h-full items-end pb-0"
83 >
84 <div
85 class="w-[12px] bg-surface-gray-2 rounded-t-1"
86 :style="{ height: [20, 20, 30, 15, 10, 10][idx - 1] + '%' }"
87 />
88 <div
89 class="w-[12px] bg-surface-gray-2 rounded-t-1"
90 :style="{ height: [60, 55, 85, 45, 30, 20][idx - 1] + '%' }"
91 />
92 </div>
93 </div>
94 <div class="flex justify-around mt-3 mb-2 px-6">
95 <div
96 class="w-6 h-2 bg-surface-gray-2 rounded-4"
97 v-for="i in 6"
98 :key="i"
99 />
100 </div>
101 </div>
102 <EmptyState
103 class="absolute inset-0 z-10"
104 :title="__('No average metrics')"
105 :description="
106 __('Average response and resolution metrics not available')
107 "
108 variant="overlay"
109 text="md"
110 />
111 </div>
112 <div v-else class="flex flex-col mt-5 grow w-full">
113 <div class="flex items-center gap-12">
114 <div>
115 <div class="text-md-medium text-ink-gray-8">
116 {{ timeAverages.first_response }}
117 </div>
118 <div class="text-base text-ink-gray-5 flex items-center gap-2 mt-1">
119 <div class="size-2 bg-surface-gray-10 rounded-full" />
120 {{ __("Avg. first response time") }}
121 </div>
122 </div>
123 <div>
124 <div class="text-md-medium text-ink-gray-8">
125 {{ timeAverages.resolution }}
126 </div>
127 <div class="text-base text-ink-gray-5 flex items-center gap-2 mt-1">
128 <div class="size-2 bg-surface-gray-4 rounded-full" />
129 {{ __("Avg. resolution time") }}
130 </div>
131 </div>
132 </div>
133 <div class="w-full grow">
134 <ECharts :options="chartConfig" class="w-full h-full" />
135 </div>
136 </div>
137 </div>
138</template>
139
140<script setup lang="ts">
141import LucideCheck from "~icons/lucide/check";
142import { computed, onMounted, ref, type PropType, nextTick } from "vue";
143import { EChartsOption } from "echarts";
144import { createResource, Dropdown, DateRangePicker, Button } from "frappe-ui";
145import { ECharts } from "frappe-ui/experimental";
146import { dataTheme, formatTime } from "@/utils";
147import { __ } from "@/translation";
148import EmptyState from "@/components/EmptyState.vue";
149
150type MetricsData = {
151 averages: {
152 first_response: number;
153 resolution: number;
154 };
155 data: [string, number, number][];
156};
157
158const props = defineProps({
159 data: {
160 type: Object as PropType<MetricsData>,
161 required: true,
162 },
163});
164
165const currentDuration = ref("6m");
166const datePickerRef = ref<{ open: () => void } | null>(null);
167const customDateRange = ref<string | undefined>(undefined);
168
169const durationLabels: Record<string, string> = {
170 "3m": __("3 Months"),
171 "6m": __("6 Months"),
172 "1y": __("1 Year"),
173 custom_range: __("Custom Range"),
174};
175
176const currentDurationLabel = computed(() => {
177 return durationLabels[currentDuration.value] || __("6 Months");
178});
179
180const durationOptions = computed(() => [
181 {
182 label: __("3 Months"),
183 onClick: () => onDurationChange("3m"),
184 },
185 {
186 label: __("6 Months"),
187 onClick: () => onDurationChange("6m"),
188 },
189 {
190 label: __("1 Year"),
191 onClick: () => onDurationChange("1y"),
192 },
193 {
194 label: __("Custom Range"),
195 onClick: () => {
196 currentDuration.value = "custom_range";
197 nextTick(() => {
198 datePickerRef.value?.open();
199 });
200 },
201 },
202]);
203
204// dismissing the calendar without a range would otherwise leave an empty
205// field where the preset menu used to be, with no way back to it
206const onPickerToggle = (open: boolean) => {
207 if (!open && !customDateRange.value) currentDuration.value = "6m";
208};
209
210const onCustomRangeSelected = (range: string[]) => {
211 if (!range?.length) {
212 currentDuration.value = "6m";
213 customDateRange.value = undefined;
214 getAvgTimeMetricsResource.submit();
215 return;
216 }
217 currentDuration.value = "custom_range";
218 customDateRange.value = range.join(",");
219 getAvgTimeMetricsResource.submit();
220};
221
222const getAvgTimeMetricsResource = createResource({
223 url: "opendesk.api.agent_home.agent_home.get_avg_time_metrics",
224 type: "GET",
225 makeParams: () => {
226 const params: Record<string, string> = {
227 period: currentDuration.value.toLowerCase(),
228 };
229 if (currentDuration.value === "custom_range" && customDateRange.value) {
230 const [from, to] = customDateRange.value.split(",");
231 params.from_date = from;
232 params.to_date = to;
233 }
234 return params;
235 },
236});
237
238const timeAverages = computed(() => {
239 const _data = getAvgTimeMetricsResource.fetched
240 ? getAvgTimeMetricsResource.data
241 : props.data;
242 const _averageFirstResponse = _data?.averages?.first_response || 0;
243 const _averageResolution = _data?.averages?.resolution || 0;
244
245 return {
246 first_response:
247 formatTime(_averageFirstResponse, {
248 day: true,
249 hour: true,
250 minute: true,
251 }) || "0m",
252 resolution:
253 formatTime(_averageResolution, {
254 day: true,
255 hour: true,
256 minute: true,
257 }) || "0m",
258 };
259});
260
261const cssVar = (name: string) =>
262 getComputedStyle(document.documentElement).getPropertyValue(name).trim();
263
264const chartConfig = computed<EChartsOption>(() => {
265 // re-compute on theme change
266 void dataTheme.value;
267
268 let data = getAvgTimeMetricsResource.fetched
269 ? getAvgTimeMetricsResource.data?.data
270 : props.data?.data || [];
271
272 return {
273 legend: {},
274 tooltip: {
275 trigger: "item",
276 backgroundColor: cssVar("--surface-base"),
277 borderColor: cssVar("--outline-gray-2"),
278 borderWidth: 1,
279 padding: 10,
280 textStyle: {
281 color: cssVar("--ink-gray-9"),
282 },
283 formatter: (params) => {
284 const p = params as unknown as {
285 data: [string, number, number];
286 seriesIndex: number;
287 };
288 const [category, firstResponse, resolution] = p.data;
289 if (p.seriesIndex === 0) {
290 return `<span>${category}</span><br/>${__(
291 "Avg. first response time"
292 )}: <b>${formatTime(firstResponse, {
293 day: true,
294 hour: true,
295 minute: true,
296 })}</b>`;
297 } else {
298 return `<span>${category}</span><br/>${__(
299 "Avg. resolution time"
300 )}: <b>${formatTime(resolution, {
301 day: true,
302 hour: true,
303 minute: true,
304 })}</b>`;
305 }
306 },
307 },
308 dataset: {
309 source: data,
310 },
311 xAxis: {
312 type: "category",
313 axisLine: { show: false },
314 axisTick: { show: false },
315 },
316 yAxis: {
317 axisLabel: {
318 formatter: (value: number) => {
319 if (value < 3600) {
320 return (value / 60).toFixed(0) + "m";
321 } else if (value < 86400) {
322 return (value / 3600).toFixed(0) + "h";
323 } else {
324 return (value / 86400).toFixed(0) + "d";
325 }
326 },
327 margin: 20,
328 },
329 axisTick: { show: true },
330 splitLine: {
331 lineStyle: {
332 type: "dashed",
333 color: cssVar("--outline-gray-2"),
334 width: 0.8,
335 },
336 },
337 },
338 series: [
339 {
340 type: "bar",
341 color: cssVar("--surface-gray-10"),
342 barWidth: "12%",
343 itemStyle: { borderRadius: [4, 4, 0, 0] },
344 },
345 {
346 type: "bar",
347 color: cssVar("--surface-gray-4"),
348 barWidth: "12%",
349 itemStyle: { borderRadius: [4, 4, 0, 0] },
350 },
351 ],
352 grid: {
353 left: 50,
354 right: 50,
355 top: 30,
356 bottom: 30,
357 },
358 };
359});
360
361const onDurationChange = (duration: string) => {
362 if (currentDuration.value === duration) return;
363 customDateRange.value = undefined;
364 currentDuration.value = duration;
365 getAvgTimeMetricsResource.submit();
366};
367
368onMounted(() => {
369 if (!props.data?.data) {
370 getAvgTimeMetricsResource.submit();
371 }
372});
373</script>
374