1<template>
2 <div class="w-full h-full overflow-hidden">
3 <div class="flex flex-col rounded-5 p-4 min-h-48 grow w-full h-full">
4 <div class="flex flex-col sm:flex-row gap-4 h-full w-full">
5 <div class="flex items-center justify-between sm:hidden">
6 <div class="text-ink-gray-8 text-md-semibold">
7 {{ __("Reviews") }}
8 </div>
9 <TabButtons
10 :options="chartTabs"
11 v-model="currentTab"
12 class="sm:hidden z-20"
13 />
14 </div>
15 <!-- Left Panel: Summary Stats -->
16 <div
17 class="flex flex-col gap-4 min-w-72"
18 :class="{ 'hidden sm:flex': currentTab === 'feedback' }"
19 >
20 <div class="items-center justify-between hidden sm:flex">
21 <div class="text-ink-gray-8 text-md-semibold">
22 {{ __("Reviews") }}
23 </div>
24 <TabButtons
25 :options="chartTabs"
26 v-model="currentTab"
27 class="sm:hidden"
28 />
29 </div>
30 <!-- Average Rating -->
31 <div
32 v-if="chartConfig.totalFeedbacks > 0"
33 class="flex items-end justify-between gap-4"
34 >
35 <div class="flex flex-col gap-1">
36 <div class="flex items-center gap-1">
37 <LucideStar class="size-4 fill-[#de9735] text-[#de9735]" />
38 <div class="text-2xl-medium text-ink-gray-8">
39 {{ chartConfig.averageRating }}
40 </div>
41 </div>
42 <div
43 class="flex items-center text-ink-gray-5 text-sm cursor-pointer hover:text-ink-gray-7"
44 @click="redirectToSeeAllReviews"
45 >
46 {{ __("{0} reviews", chartConfig.totalFeedbacks) }}
47 <LucideArrowUpRight class="size-3.5 ms-0.5" />
48 </div>
49 </div>
50 <div v-if="chartConfig.totalFeedbacks > 0" class="text-sm">
51 {{ __("Your performance is") }}
52 {{ performance }}!
53 </div>
54 </div>
55 <div v-else>
56 <div class="flex flex-col gap-1">
57 <div class="flex items-center gap-1">
58 <div class="size-4 bg-surface-gray-1 rounded-1" />
59 <div class="w-12 h-4 bg-surface-gray-1 rounded-1" />
60 </div>
61 </div>
62 <div class="text-sm mt-3">
63 <div class="w-24 h-4 bg-surface-gray-1 rounded-1" />
64 </div>
65 </div>
66 <!-- Bar Chart -->
67 <div class="h-32 mt-2 w-full px-4">
68 <ECharts
69 v-if="chartConfig.totalFeedbacks > 0"
70 :options="barChartOptions"
71 class="w-full h-full"
72 />
73 <ECharts
74 v-else
75 :options="placeholderChartOptions"
76 class="w-full h-full"
77 />
78 </div>
79 </div>
80 <div class="hidden sm:block h-full w-[1px] bg-surface-gray-1" />
81
82 <!-- Right Panel: Feedback Card -->
83 <div
84 class="flex-1 flex flex-col min-w-0"
85 :class="{ 'hidden sm:flex': currentTab === 'rating' }"
86 >
87 <!-- Filters -->
88 <div class="flex items-center gap-2 justify-between mb-3 z-20">
89 <!-- Sort dropdown first -->
90 <Dropdown
91 v-if="chartConfig.totalFeedbacks !== 0"
92 :options="sortOptions"
93 align="start"
94 >
95 <template #default>
96 <Button
97 :label="currentSortLabel"
98 icon-right="lucide-chevron-down"
99 />
100 </template>
101 <template #item-label="{ item }">
102 <div
103 class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
104 >
105 <span>{{ item.label }}</span>
106 </div>
107 </template>
108 <template #item-suffix="{ item }">
109 <LucideCheck
110 v-if="item.label == __(sortLabels[currentSort])"
111 class="size-4"
112 />
113 </template>
114 </Dropdown>
115
116 <!-- Period dropdown second -->
117 <div class="flex items-center gap-2 ms-auto">
118 <Dropdown
119 v-if="!showDatePicker && currentPeriod !== 'custom_range'"
120 :options="periodOptions"
121 align="end"
122 >
123 <template #default>
124 <Button
125 :label="currentPeriodLabel"
126 icon-right="lucide-chevron-down"
127 />
128 </template>
129 <template #item-label="{ item }">
130 <div
131 class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
132 >
133 <span>
134 {{ item.label }}
135 </span>
136 </div>
137 </template>
138 <template #item-suffix="{ item }">
139 <LucideCheck
140 v-if="item.label == __(periodLabels[currentPeriod])"
141 class="size-4"
142 />
143 </template>
144 </Dropdown>
145 <DateRangePicker
146 v-if="showDatePicker || currentPeriod === 'custom_range'"
147 ref="datePickerRef"
148 :model-value="customDateRange ? customDateRange.split(',') : []"
149 :placeholder="__('Select range')"
150 @update:model-value="onCustomRangeSelected"
151 @update:open="onPickerToggle"
152 :format="'MMM D'"
153 side="top"
154 align="start"
155 class="!w-48"
156 />
157 </div>
158 </div>
159 <div class="flex-1 flex flex-col min-h-0">
160 <!-- Feedback Card -->
161 <div
162 v-if="currentFeedback && chartConfig.totalFeedbacks > 0"
163 class="flex-1 flex flex-col rounded-6 mt-2 relative z-20"
164 >
165 <!-- Ticket Info -->
166 <div class="flex items-center gap-1 text-base text-ink-gray-5">
167 <div
168 class="flex items-center gap-0.5 hover:text-ink-gray-7 cursor-pointer font-medium"
169 @click="goToTicket(currentFeedback)"
170 >
171 <LucideArrowUpRight class="size-4" />
172 {{ currentFeedback.name }}
173 </div>
174 <span class="text-ink-gray-4">·</span>
175 <span class="truncate text-ink-gray-7 font-medium">{{
176 currentFeedback.subject
177 }}</span>
178 </div>
179 <hr class="my-2" />
180 <!-- Rating & Title -->
181 <div class="flex items-center gap-2 mb-2">
182 <div
183 class="flex items-center gap-1 p-1 ps-0 rounded-4"
184 :class="[getRatingColor(currentFeedback.star_rating).text]"
185 >
186 <LucideStar
187 class="size-3.5"
188 :class="getRatingColor(currentFeedback.star_rating).text"
189 />
190 <span
191 class="text-base-medium text-ink-gray-7"
192 :class="getRatingColor(currentFeedback.star_rating).text"
193 >{{ currentFeedback.star_rating }}</span
194 >
195 </div>
196 <span class="text-base-medium text-ink-gray-7">{{
197 currentFeedback.feedback || __("Feedback")
198 }}</span>
199 </div>
200
201 <!-- Feedback Text -->
202 <div class="text-p-base text-ink-gray-7 mb-3 line-clamp-3">
203 {{
204 currentFeedback.feedback_extra || __("No additional comments")
205 }}
206 </div>
207
208 <!-- Contact & Navigation -->
209 <div class="flex items-center justify-between mt-auto">
210 <div class="flex items-center gap-1">
211 <Avatar
212 :image="currentFeedback.contact_image"
213 :label="
214 currentFeedback.contact_name || currentFeedback.contact
215 "
216 size="sm"
217 />
218 <span class="text-sm text-ink-gray-6">{{
219 currentFeedback.contact_name || currentFeedback.contact
220 }}</span>
221 <span class="text-sm text-ink-gray-4">·</span>
222 <span class="text-sm text-ink-gray-5">{{
223 timeAgo(currentFeedback.modified)
224 }}</span>
225 </div>
226 <div class="flex items-center gap-1">
227 <Button
228 variant="ghost"
229 size="sm"
230 @click="prevFeedback"
231 :disabled="currentIndex === 0"
232 >
233 <LucideChevronLeft class="size-4 rtl:rotate-180" />
234 </Button>
235 <Button
236 variant="ghost"
237 size="sm"
238 @click="nextFeedback"
239 :disabled="currentIndex >= chartConfig.feedbacks.length - 1"
240 >
241 <LucideChevronRight class="size-4 rtl:rotate-180" />
242 </Button>
243 </div>
244 </div>
245 </div>
246 <div
247 v-else
248 class="flex-1 flex flex-col rounded-6 mt-2 select-none pointer-events-none"
249 >
250 <div class="flex items-center gap-1 py-1">
251 <div class="w-64 h-4 bg-surface-gray-1 rounded-1" />
252 </div>
253 <hr class="my-2 border-outline-gray-2" />
254 <div class="flex items-center gap-2 mb-3 mt-1">
255 <div class="w-12 h-5 bg-surface-gray-1 rounded-1" />
256 <div class="w-56 h-5 bg-surface-gray-1 rounded-1" />
257 </div>
258 <div class="w-48 h-4 bg-surface-gray-1 rounded-1 mb-3" />
259 <div class="flex items-center justify-between mt-auto">
260 <div class="flex items-center gap-2">
261 <div class="w-6 h-6 rounded-full bg-surface-gray-1" />
262 <div class="w-32 h-3 bg-surface-gray-1 rounded-1" />
263 </div>
264 <div class="flex items-center gap-1">
265 <div class="w-16 h-6 bg-surface-gray-1 rounded-1" />
266 </div>
267 </div>
268 </div>
269 </div>
270 </div>
271 <div class="z-10" v-if="chartConfig.totalFeedbacks === 0">
272 <EmptyState
273 class="absolute inset-0 z-10"
274 variant="overlay"
275 :title="__('No feedback')"
276 :description="__('You haven\'t received any feedback yet')"
277 text="md"
278 />
279 </div>
280 </div>
281 </div>
282 </div>
283</template>
284
285<script setup lang="ts">
286import LucideArrowUpRight from "~icons/lucide/arrow-up-right";
287import LucideCheck from "~icons/lucide/check";
288import LucideChevronLeft from "~icons/lucide/chevron-left";
289import LucideChevronRight from "~icons/lucide/chevron-right";
290import { ref, computed, onMounted, nextTick, type PropType } from "vue";
291import {
292 Avatar,
293 Button,
294 createResource,
295 DateRangePicker,
296 Dropdown,
297 TabButtons,
298} from "frappe-ui";
299import { ECharts } from "frappe-ui/experimental";
300import { dataTheme } from "@/utils";
301import LucideStar from "~icons/lucide/star";
302import { useRouter } from "vue-router";
303import { dayjsLocal } from "frappe-ui";
304import { __ } from "@/translation";
305import { timeAgo } from "@/utils";
306import type { EChartsOption } from "echarts";
307import { useView } from "@/composables/useView";
308import { View } from "@/types";
309import EmptyState from "@/components/EmptyState.vue";
310
311const router = useRouter();
312const chartTabs = [
313 { label: __("Rating"), value: "rating" },
314 { label: __("Feedback"), value: "feedback" },
315];
316const currentTab = ref("rating");
317const { views } = useView("OD Ticket");
318
319interface Feedback {
320 name: string;
321 subject: string;
322 feedback_rating: number;
323 star_rating: number;
324 feedback: string;
325 feedback_extra: string;
326 contact: string;
327 contact_name: string;
328 contact_image: string;
329 modified: string;
330}
331
332interface Data {
333 recent_feedbacks: Feedback[];
334 total_feedbacks: number;
335 average_rating: number;
336 rating_distribution: Record<number, number>;
337}
338
339const props = defineProps({
340 data: {
341 type: Object as PropType<Data>,
342 required: true,
343 },
344});
345
346const currentIndex = ref(0);
347const currentPeriod = ref("all_time");
348const currentSort = ref("positive_first");
349const showDatePicker = ref(false);
350const datePickerRef = ref<{ open: () => void } | null>(null);
351const customDateRange = ref<string | undefined>(undefined);
352
353const periodOptions = computed(() => [
354 {
355 label: __("All Time"),
356 onClick: () => changePeriod("all_time"),
357 },
358 {
359 label: __("Last Week"),
360 onClick: () => changePeriod("last_week"),
361 },
362 {
363 label: __("Last Month"),
364 onClick: () => changePeriod("last_month"),
365 },
366 {
367 label: __("Last 3 Months"),
368 onClick: () => changePeriod("last_3_months"),
369 },
370 {
371 label: __("Custom Range"),
372 onClick: () => {
373 showDatePicker.value = true;
374 nextTick(() => {
375 datePickerRef.value?.open();
376 });
377 },
378 },
379]);
380
381const sortOptions = computed(() => [
382 {
383 label: __("Positive first"),
384 onClick: () => changeSort("positive_first"),
385 },
386 {
387 label: __("Negative first"),
388 onClick: () => changeSort("negative_first"),
389 },
390]);
391
392const periodLabels: Record<string, string> = {
393 all_time: __("All Time"),
394 last_week: __("Last Week"),
395 last_month: __("Last Month"),
396 last_3_months: __("Last 3 Months"),
397 custom_range: __("Custom Range"),
398};
399
400const currentPeriodLabel = computed(() => {
401 return periodLabels[currentPeriod.value] || __("All Time");
402});
403
404const sortLabels: Record<string, string> = {
405 negative_first: __("Negative first"),
406 positive_first: __("Positive first"),
407};
408
409const currentSortLabel = computed(() => {
410 return sortLabels[currentSort.value] || __("Positive first");
411});
412
413const changePeriod = (period: string) => {
414 if (currentPeriod.value == period) return;
415 showDatePicker.value = false;
416 customDateRange.value = undefined;
417 currentPeriod.value = period;
418 currentIndex.value = 0;
419 getRecentFeedbackResource.fetch();
420};
421
422const changeSort = (sort: string) => {
423 if (currentSort.value == sort) return;
424 currentSort.value = sort;
425 currentIndex.value = 0;
426 getRecentFeedbackResource.fetch();
427};
428
429// dismissing the calendar without a range would otherwise leave an empty
430// field where the period menu used to be, with no way back to it
431const onPickerToggle = (open: boolean) => {
432 if (!open && !customDateRange.value) {
433 showDatePicker.value = false;
434 currentPeriod.value = "all_time";
435 }
436};
437
438const onCustomRangeSelected = (range: string[]) => {
439 if (!range?.length) {
440 showDatePicker.value = false;
441 currentPeriod.value = "all_time";
442 customDateRange.value = undefined;
443 currentIndex.value = 0;
444 getRecentFeedbackResource.fetch();
445 return;
446 }
447 showDatePicker.value = false;
448 currentPeriod.value = "custom_range";
449 customDateRange.value = range.join(",");
450 currentIndex.value = 0;
451 getRecentFeedbackResource.fetch();
452};
453
454const chartConfig = computed(() => {
455 const _data = getRecentFeedbackResource.data ?? props.data;
456 const feedbacks = _data?.recent_feedbacks || [];
457 const totalFeedbacks = _data?.total_feedbacks || 0;
458 const averageRating = _data?.average_rating || 0;
459 const ratingDistribution = _data?.rating_distribution || {
460 1: 0,
461 2: 0,
462 3: 0,
463 4: 0,
464 5: 0,
465 };
466
467 return {
468 feedbacks,
469 totalFeedbacks,
470 averageRating,
471 ratingDistribution,
472 };
473});
474
475const performance = computed(() => {
476 if (chartConfig.value.averageRating >= 4) {
477 return __("excellent");
478 } else if (chartConfig.value.averageRating >= 3) {
479 return __("good");
480 } else if (chartConfig.value.averageRating >= 2) {
481 return __("average");
482 } else {
483 return __("poor");
484 }
485});
486
487const currentFeedback = computed(() => {
488 return chartConfig.value.feedbacks[currentIndex.value] || null;
489});
490
491const barChartOptions = computed<EChartsOption>(() => {
492 // re-compute on theme change
493 void dataTheme.value;
494
495 const mutedBarColor = cssVar("--surface-gray-4");
496 const axisLineColor = cssVar("--outline-gray-1");
497 const axisLabelColor = cssVar("--ink-gray-5");
498 const goldBarColor = "#de9735"; // brand rating gold, intentional
499
500 const distribution = chartConfig.value.ratingDistribution;
501 const values = [1, 2, 3, 4, 5].map((star) => distribution[star] || 0);
502 const maxValue = Math.max(...values);
503
504 const data = [1, 2, 3, 4, 5].map((star, index) => {
505 const value = values[index];
506 const isMax = value > 0 && value === maxValue;
507 return {
508 value,
509 itemStyle: {
510 color: isMax ? goldBarColor : mutedBarColor,
511 borderRadius: [4, 4, 0, 0],
512 },
513 label: {
514 show: isMax,
515 position: "top" as const,
516 formatter: value > 0 ? String(value) : "",
517 color: axisLabelColor,
518 fontSize: 12,
519 },
520 emphasis: {
521 label: {
522 show: true,
523 },
524 },
525 };
526 });
527
528 return {
529 grid: {
530 left: 4,
531 right: 4,
532 top: 16,
533 bottom: 20,
534 containLabel: false,
535 },
536 xAxis: {
537 type: "category",
538 data: ["1", "2", "3", "4", "5"],
539 axisLine: { show: true, lineStyle: { color: axisLineColor } },
540 axisTick: { show: false },
541 axisLabel: {
542 color: axisLabelColor,
543 fontSize: 12,
544 },
545 },
546 yAxis: {
547 type: "value",
548 show: false,
549 },
550 series: [
551 {
552 type: "bar",
553 data,
554 barWidth: 24,
555 label: {
556 position: "top",
557 color: axisLabelColor,
558 fontSize: 12,
559 formatter: (params: any) => (params.value > 0 ? params.value : ""),
560 },
561 emphasis: {
562 focus: "none",
563 },
564 },
565 ],
566 tooltip: { show: false },
567 };
568});
569
570const cssVar = (name: string) =>
571 getComputedStyle(document.documentElement).getPropertyValue(name).trim();
572
573const placeholderChartOptions = computed<EChartsOption>(() => {
574 // re-compute on theme change
575 void dataTheme.value;
576
577 const placeholderColor = cssVar("--surface-gray-3");
578 const axisLineColor = cssVar("--outline-gray-2");
579 const axisLabelColor = cssVar("--ink-gray-5");
580
581 const placeholderValues = Array.from(
582 { length: 5 },
583 () => Math.floor(Math.random() * 11) + 3
584 );
585 const data = placeholderValues.map((value) => ({
586 value,
587 itemStyle: {
588 color: placeholderColor,
589 borderRadius: [4, 4, 0, 0],
590 },
591 }));
592
593 return {
594 grid: {
595 left: 4,
596 right: 4,
597 top: 16,
598 bottom: 20,
599 containLabel: false,
600 },
601 xAxis: {
602 type: "category",
603 data: ["1", "2", "3", "4", "5"],
604 axisLine: { show: true, lineStyle: { color: axisLineColor } },
605 axisTick: { show: false },
606 axisLabel: {
607 color: axisLabelColor,
608 fontSize: 12,
609 },
610 },
611 yAxis: {
612 type: "value",
613 show: false,
614 },
615 series: [
616 {
617 type: "bar",
618 data,
619 barWidth: 24,
620 label: { show: false },
621 emphasis: { disabled: true },
622 },
623 ],
624 tooltip: { show: false },
625 animation: false,
626 };
627});
628
629const redirectToSeeAllReviews = () => {
630 const viewName = "STD-VIEW-ALL-FEEDBACK";
631 const view = views.data?.find((v: View) => v.name === viewName);
632
633 const query: any = {
634 view: view?.name,
635 };
636
637 if (currentPeriod.value !== "all_time") {
638 let dateFilter = "";
639 if (currentPeriod.value === "custom_range" && customDateRange.value) {
640 const [from, to] = customDateRange.value.split(",");
641 query.filters = JSON.stringify({
642 modified: ["between", [`${from} 00:00:00`, `${to} 23:59:59`]],
643 });
644 } else {
645 if (currentPeriod.value === "last_week") {
646 dateFilter = dayjsLocal()
647 .subtract(7, "day")
648 .format("YYYY-MM-DD HH:mm:ss");
649 } else if (currentPeriod.value === "last_month") {
650 dateFilter = dayjsLocal()
651 .subtract(30, "day")
652 .format("YYYY-MM-DD HH:mm:ss");
653 } else if (currentPeriod.value === "last_3_months") {
654 dateFilter = dayjsLocal()
655 .subtract(90, "day")
656 .format("YYYY-MM-DD HH:mm:ss");
657 }
658
659 if (dateFilter) {
660 query.filters = JSON.stringify({
661 modified: [">", dateFilter],
662 });
663 }
664 }
665 }
666
667 const route = router.resolve({
668 name: "TicketsAgent",
669 query,
670 });
671 window.open(route.href, "_blank");
672};
673
674const getRatingColor = (rating: number) => {
675 if (rating >= 4)
676 return {
677 text: "text-ink-green-6 fill-ink-green-6",
678 bg: "bg-surface-green-2",
679 };
680 if (rating >= 3)
681 return {
682 text: "text-ink-yellow-3 fill-ink-yellow-3",
683 bg: "bg-surface-yellow-2",
684 };
685 return {
686 text: "text-ink-red-6 fill-ink-red-6",
687 bg: "bg-surface-red-2",
688 };
689};
690
691const getRecentFeedbackResource = createResource({
692 url: "opendesk.api.agent_home.agent_home.get_recent_feedback",
693 makeParams: () => {
694 const params: Record<string, string> = {
695 period: currentPeriod.value,
696 sort_order: currentSort.value,
697 };
698 if (currentPeriod.value === "custom_range" && customDateRange.value) {
699 const [from, to] = customDateRange.value.split(",");
700 params.from_date = from;
701 params.to_date = to;
702 }
703 return params;
704 },
705});
706
707const goToTicket = (feedback: Feedback) => {
708 const route = router.resolve({
709 name: "TicketAgent",
710 params: { ticketId: feedback.name },
711 });
712 window.open(route.href, "_blank");
713};
714
715const nextFeedback = () => {
716 if (currentIndex.value < chartConfig.value.feedbacks.length - 1) {
717 currentIndex.value++;
718 }
719};
720
721const prevFeedback = () => {
722 if (currentIndex.value > 0) {
723 currentIndex.value--;
724 }
725};
726
727onMounted(() => {
728 if (!props.data?.recent_feedbacks) {
729 getRecentFeedbackResource.submit();
730 }
731});
732</script>
733