1<template>
2 <div class="flex flex-col rounded-5 p-4 grow w-full h-full overflow-hidden">
3 <div class="flex gap-4 items-center justify-between">
4 <div class="flex items-center gap-2 text-md-semibold text-ink-gray-8">
5 {{ currentTitle }}
6 <Tooltip :text="tooltipText" side="top">
7 <LucideInfo class="size-3" />
8 </Tooltip>
9 </div>
10 <div class="w-max">
11 <TabButtons :options="chartTabs" v-model="currentTab" />
12 </div>
13 </div>
14 <div class="flex flex-col mt-5 grow overflow-auto hide-scrollbar">
15 <table
16 class="w-full table-auto transition-opacity duration-150"
17 :class="{
18 'opacity-60': getPendingTicketsResource.loading && !showSkeleton,
19 }"
20 >
21 <thead v-if="!showSkeleton && chartConfig?.tickets?.length > 0">
22 <tr class="text-sm text-ink-gray-5">
23 <th class="p-2 text-start font-normal whitespace-nowrap">
24 {{ __("ID") }}
25 </th>
26 <th class="p-2 text-start font-normal w-full">
27 {{ __("Subject") }}
28 </th>
29 <th class="p-2 text-start font-normal min-w-16 whitespace-nowrap">
30 {{ __("Status") }}
31 </th>
32 <th class="p-2 text-start font-normal min-w-28 whitespace-nowrap">
33 {{ __("Priority") }}
34 </th>
35 <th class="p-2 text-start font-normal min-w-44 whitespace-nowrap">
36 {{ __("Team") }}
37 </th>
38 <th class="p-2 text-start font-normal min-w-40 whitespace-nowrap">
39 {{ __("Reason") }}
40 </th>
41 </tr>
42 </thead>
43 <tbody
44 v-if="!showSkeleton && chartConfig?.tickets?.length > 0"
45 class="grow"
46 >
47 <tr
48 v-for="ticket in chartConfig?.tickets"
49 :key="ticket.name"
50 @click="goToTicket(ticket)"
51 class="text-sm cursor-pointer hover:bg-surface-sidebar border-t border-outline-elevation-2"
52 >
53 <td class="p-2 py-3 whitespace-nowrap">{{ ticket.name }}</td>
54 <td class="p-2 py-3 w-full max-w-0 truncate">
55 {{ ticket.subject }}
56 </td>
57 <td class="p-2 py-3 min-w-16 truncate">
58 {{ ticket.status }}
59 </td>
60 <td class="p-2 py-3 min-w-28 truncate">
61 <TicketPriority :priority="ticket.priority" />
62 </td>
63 <td class="p-2 py-3 min-w-44 truncate">
64 {{ ticket.agent_group || __("Not Assigned") }}
65 </td>
66 <td class="p-2 py-3 min-w-40">
67 <div
68 v-if="ticket.reason"
69 class="flex items-center gap-1 text-ink-gray-7 truncate w-full"
70 :class="getReasonColorClass(ticket.reason)"
71 >
72 <TimerIcon
73 v-if="ticket.reason.type === 'upcoming_sla'"
74 class="size-4 flex-shrink-0"
75 />
76 <TicketPlusIcon
77 v-else-if="ticket.reason.type === 'new_tickets'"
78 class="size-4 flex-shrink-0"
79 />
80 <CalendarIcon
81 v-else-if="ticket.reason.type === 'pending'"
82 class="size-4 flex-shrink-0"
83 />
84 <span class="truncate pe-2">{{ ticket.reason.text }}</span>
85 </div>
86 <span
87 v-else
88 class="text-ink-gray-4 truncate inline-block w-full align-bottom"
89 >{{ __("No reason") }}</span
90 >
91 </td>
92 </tr>
93 </tbody>
94 <tbody class="relative" v-else>
95 <tr
96 v-for="i in 8"
97 :key="i"
98 :class="i > 1 ? 'border-t border-outline-elevation-2' : ''"
99 >
100 <td class="p-2 py-3 min-w-8">
101 <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
102 </td>
103 <td class="p-2 py-3 w-full max-w-0">
104 <div class="h-4 w-full rounded-1 bg-surface-gray-1 max-w-full" />
105 </td>
106 <td class="p-2 py-3 min-w-14">
107 <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
108 </td>
109 <td class="p-2 py-3 min-w-21">
110 <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
111 </td>
112 <td class="p-2 py-3 min-w-28">
113 <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
114 </td>
115 <td class="p-2 py-3 min-w-40">
116 <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
117 </td>
118 </tr>
119 <EmptyState
120 class="absolute inset-0 z-10"
121 v-if="
122 !getPendingTicketsResource.loading &&
123 chartConfig?.tickets?.length === 0
124 "
125 :title="emptyState.title"
126 :description="emptyState.description"
127 variant="overlay"
128 text="md"
129 />
130 </tbody>
131 </table>
132 <div
133 class="flex justify-between items-center text-sm mt-auto text-ink-gray-5 ps-2 pb-2"
134 >
135 <div>
136 <div
137 v-if="chartConfig?.totalPendingTickets > 6"
138 class="flex items-center gap-1 cursor-pointer hover:text-ink-gray-7 w-max select-none mt-3"
139 @click="redirectToSeeAllTickets"
140 >
141 {{
142 __("See all {0} tickets", chartConfig?.totalPendingTickets + "")
143 }}
144 <LucideArrowRight class="size-4 rtl:rotate-180" />
145 </div>
146 </div>
147 <div v-if="chartConfig?.tickets?.length > 0" class="mt-3 mb-0.5">
148 {{
149 __(
150 "Showing {0} of {1} {2}",
151 chartConfig?.tickets?.length + "",
152 chartConfig?.totalPendingTickets + "",
153 chartConfig?.tickets?.length > 1 ? __("tickets") : __("ticket")
154 )
155 }}
156 </div>
157 </div>
158 </div>
159 </div>
160</template>
161
162<script setup lang="ts">
163import LucideArrowRight from "~icons/lucide/arrow-right";
164import LucideInfo from "~icons/lucide/info";
165import EmptyState from "@/components/EmptyState.vue";
166import TicketPriority from "@/components/TicketPriority.vue";
167import { useView } from "@/composables/useView";
168import { __ } from "@/translation";
169import { View } from "@/types";
170import { createResource, TabButtons } from "frappe-ui";
171import { computed, onMounted, ref, watch, type PropType } from "vue";
172import { useRouter } from "vue-router";
173import CalendarIcon from "~icons/lucide/calendar";
174import TicketPlusIcon from "~icons/lucide/ticket-plus";
175import TimerIcon from "~icons/lucide/timer";
176
177interface TicketReason {
178 type: string;
179 text: string;
180 seconds_until_due?: number;
181}
182
183interface PendingTicket {
184 name: string;
185 subject: string;
186 status: string;
187 priority: string;
188 agent_group?: string;
189 agreement_status?: string;
190 creation: string;
191 resolution_by?: string;
192 response_by?: string;
193 reason?: TicketReason;
194}
195
196interface PendingTicketsData {
197 tickets: PendingTicket[];
198 total_pending_tickets: number;
199}
200
201const props = defineProps({
202 data: {
203 type: Object as PropType<PendingTicketsData>,
204 required: true,
205 },
206});
207
208const router = useRouter();
209const { views } = useView("OD Ticket");
210const currentTab = ref("upcoming_sla");
211const chartTabs = [
212 {
213 label: __("SLA"),
214 value: "upcoming_sla",
215 },
216 {
217 label: __("Pending"),
218 value: "pending",
219 },
220 {
221 label: __("Recent"),
222 value: "new_tickets",
223 },
224];
225
226const titles: Record<string, string> = {
227 upcoming_sla: __("SLA Alerts"),
228 new_tickets: __("Recent Tickets"),
229 pending: __("Pending Tickets"),
230};
231
232const currentTitle = computed(() => {
233 return titles[currentTab.value] || __("Pending Tickets");
234});
235
236const tooltipTexts: Record<string, string> = {
237 upcoming_sla: __("Tickets approaching or breached SLA"),
238 new_tickets: __("Tickets assigned to you in the last 7 days"),
239 pending: __("Tickets that are awaiting your response"),
240};
241const tooltipText = computed(() => {
242 return tooltipTexts[currentTab.value];
243});
244
245const emptyStateLabel: Record<string, { title: string; description: string }> =
246 {
247 upcoming_sla: {
248 title: __("All SLAs on track"),
249 description: __("Great! All your tickets are within SLA"),
250 },
251 new_tickets: {
252 title: __("No recently assigned tickets"),
253 description: __("No new tickets assigned to you in the last 7 days"),
254 },
255 pending: {
256 title: __("No pending tickets"),
257 description: __("All your assigned tickets have been responded to"),
258 },
259 };
260
261const emptyState = computed(() => {
262 return emptyStateLabel[currentTab.value];
263});
264
265const chartConfig = computed(() => {
266 const _data: PendingTicketsData = getPendingTicketsResource.fetched
267 ? getPendingTicketsResource.data
268 : props.data;
269 const tickets = _data.tickets;
270 const totalPendingTickets = _data.total_pending_tickets;
271
272 return {
273 tickets,
274 totalPendingTickets,
275 };
276});
277
278const hasLoadedOnce = ref(false);
279
280const getPendingTicketsResource = createResource({
281 url: "opendesk.api.agent_home.agent_home.get_pending_tickets",
282 onSuccess() {
283 hasLoadedOnce.value = true;
284 },
285});
286
287// Show the skeleton only on the first load. On later tab switches we keep the
288// previous rows visible (dimmed) until fresh data arrives, avoiding a flicker.
289const showSkeleton = computed(
290 () => getPendingTicketsResource.loading && !hasLoadedOnce.value
291);
292
293function getReasonColorClass(reason: {
294 text: string;
295 seconds_until_due?: number;
296}) {
297 if (reason.text.includes("overdue")) {
298 return "text-ink-red-6";
299 }
300
301 if (
302 reason.seconds_until_due !== undefined &&
303 reason.seconds_until_due !== null
304 ) {
305 const oneHour = 3600;
306 const twoHours = 7200;
307 if (reason.seconds_until_due <= oneHour) {
308 return "text-ink-red-6";
309 }
310 if (reason.seconds_until_due <= twoHours) {
311 return "text-ink-orange-5";
312 }
313 }
314 return "";
315}
316
317const goToTicket = (ticket: PendingTicket) => {
318 router.push({
319 name: "TicketAgent",
320 params: { ticketId: String(ticket.name) },
321 });
322};
323
324const redirectToSeeAllTickets = () => {
325 const tabToViewMap: Record<string, string> = {
326 pending: "STD-VIEW-PENDING-TICKETS",
327 upcoming_sla: "STD-VIEW-SLA-ALERTS",
328 new_tickets: "STD-VIEW-RECENTLY-ASSIGNED-TICKETS",
329 };
330
331 const viewName = tabToViewMap[currentTab.value];
332 const view = views.data?.find((v: View) => v.name === viewName);
333
334 const route = router.resolve({
335 name: "TicketsAgent",
336 query: {
337 view: view?.name,
338 },
339 });
340 window.open(route.href, "_blank");
341};
342
343onMounted(() => {
344 if (!Array.isArray(props.data.tickets)) {
345 getPendingTicketsResource.fetch({ ticket_type: currentTab.value });
346 }
347});
348
349// Watch for tab changes and fetch tickets
350watch(currentTab, (newTab) => {
351 getPendingTicketsResource.fetch({ ticket_type: newTab });
352});
353</script>
354