1<template>
2 <div>
3 <LayoutHeader>
4 <template #left-header>
5 <ViewBreadcrumbs
6 :label="__('Tickets')"
7 route-name="TicketsAgent"
8 :options="dropdownOptions"
9 :dropdown-actions="(view) => viewActions(view, viewDialogConfig)"
10 :current-view="currentView"
11 />
12 </template>
13 <template #right-header>
14 <RouterLink class="inline-flex" :to="{ name: 'TicketAgentNew' }">
15 <Button
16 class="rtl:flex-row-reverse"
17 :label="__('Create')"
18 theme="gray"
19 variant="solid"
20 >
21 <template #prefix>
22 <LucidePlus class="h-4 w-4" />
23 </template>
24 </Button>
25 </RouterLink>
26 </template>
27 </LayoutHeader>
28 <ListViewBuilder
29 ref="listViewRef"
30 :options="options"
31 @row-click="
32 (row) =>
33 $router.push({
34 name: 'TicketAgent',
35 params: { ticketId: row },
36 })
37 "
38 />
39 <ExportModal
40 v-model="showExportModal"
41 :rowCount="$refs.listViewRef?.list?.data?.total_count ?? 0"
42 @update="
43 ({ export_type, export_all }) => exportRows(export_type, export_all)
44 "
45 />
46 <ViewModal
47 v-if="viewDialogConfig.show"
48 v-model="viewDialogConfig"
49 @update="onViewModalUpdate"
50 />
51 <BulkReplyModal
52 v-model="showBulkReplyModal"
53 :selections="listSelections"
54 @success="listViewRef?.unselectAll()"
55 />
56 <BulkEditModal
57 v-model="showBulkEditModal"
58 :selections="listSelections"
59 @success="reset(true)"
60 />
61 <BulkAssignModal
62 v-model="showBulkAssignModal"
63 :selections="listSelections"
64 @success="reset(true)"
65 />
66 </div>
67</template>
68
69<script setup lang="ts">
70import { LayoutHeader, ListViewBuilder } from "@/components";
71import { TicketIcon } from "@/components/icons";
72import IndicatorIcon from "@/components/icons/IndicatorIcon.vue";
73import TicketPriority from "@/components/TicketPriority.vue";
74import BulkAssignModal from "@/components/ticket-agent/BulkAssignModal.vue";
75import BulkEditModal from "@/components/ticket-agent/BulkEditModal.vue";
76import BulkReplyModal from "@/components/ticket-agent/BulkReplyModal.vue";
77import ExportModal from "@opendesk/shared/ExportModal.vue";
78import ViewBreadcrumbs from "@/components/ViewBreadcrumbs.vue";
79import { normalizeFilters } from "@opendesk/shared/filter";
80import ViewModal from "@/components/ViewModal.vue";
81import { currentView, useView } from "@/composables/useView";
82import { useAuthStore } from "@/stores/auth";
83import { globalStore } from "@/stores/globalStore";
84import { useTicketStatusStore } from "@/stores/ticketStatus";
85import { __ } from "@/translation";
86import { View } from "@/types";
87import { resolutionBadge, responseBadge } from "@opendesk/shared/ticketCells";
88import { Badge, usePageMeta } from "frappe-ui";
89import { computed, h, onMounted, onUnmounted, reactive, ref } from "vue";
90import { useRoute, useRouter } from "vue-router";
91
92const router = useRouter();
93const route = useRoute();
94
95const {
96 getCurrentUserViews,
97 publicViews,
98 pinnedViews,
99 findView,
100 standardViews,
101 viewActions,
102 handleView,
103 resetViewDialog,
104} = useView("OD Ticket");
105
106const activeView = computed(() => findView(route.query.view as string).value);
107const hasActiveFilters = computed(
108 () => Object.keys(listViewRef.value?.list?.params?.filters || {}).length > 0
109);
110
111const { $socket } = globalStore();
112const { isManager, userId } = useAuthStore();
113
114const listViewRef = ref(null);
115const showExportModal = ref(false);
116
117const { getStatus } = useTicketStatusStore();
118
119const listSelections = ref(new Set());
120
121const showBulkReplyModal = ref(false);
122const showBulkEditModal = ref(false);
123const showBulkAssignModal = ref(false);
124
125const selectBannerActions = [
126 {
127 label: __("Reply"),
128 icon: "lucide-corner-up-left",
129 inline: true,
130 onClick: (selections: Set<string>) => {
131 listSelections.value = new Set(selections);
132 showBulkReplyModal.value = true;
133 },
134 },
135 {
136 label: __("Assign"),
137 icon: "lucide-user-plus",
138 inline: true,
139 onClick: (selections: Set<string>) => {
140 listSelections.value = new Set(selections);
141 showBulkAssignModal.value = true;
142 },
143 },
144 {
145 label: __("Export"),
146 icon: "lucide-download",
147 onClick: (selections: Set<string>) => {
148 listSelections.value = new Set(selections);
149 showExportModal.value = true;
150 },
151 },
152 {
153 label: __("Edit"),
154 icon: "lucide-pencil",
155 onClick: (selections: Set<string>) => {
156 listSelections.value = new Set(selections);
157 showBulkEditModal.value = true;
158 },
159 },
160];
161
162const options = computed(() => ({
163 doctype: "OD Ticket",
164 groupBy: true,
165 // two fields Opendesk never fills in, and the per-ticket feedback-link token
166 groupByExclude: ["avg_response_time", "user_resolution_time", "key"],
167 columnConfig: {
168 subject: {
169 custom: ({ row, item }) => {
170 const seenBy = row._seen ? JSON.parse(row._seen) : [];
171 const isSeen = seenBy.includes(userId || "");
172 return h(
173 "span",
174 {
175 class: ["truncate flex-1", !isSeen && "font-semibold"],
176 },
177 item
178 );
179 },
180 },
181 status: {
182 custom: ({ item }) => {
183 const status = getStatus(item);
184 const label = status?.["label_agent"];
185 return h(
186 "div",
187 { class: "flex items-center gap-1.5 justify-start w-full" },
188 [
189 h(IndicatorIcon, { class: status?.["parsed_color"] }),
190 h("span", { class: "truncate flex-1 text-base" }, label),
191 ]
192 );
193 },
194 },
195 priority: {
196 custom: ({ item }) => h(TicketPriority, { priority: item }),
197 },
198 agreement_status: {
199 custom: ({ item }) => {
200 if (!item) return null;
201 return h(Badge, {
202 label: __(item),
203 theme: slaStatusColorMap[item],
204 variant: "subtle",
205 });
206 },
207 },
208 response_by: {
209 custom: ({ row, item }) => responseBadge(row, item),
210 },
211 resolution_by: {
212 custom: ({ row, item }) =>
213 resolutionBadge(
214 row,
215 item,
216 getStatus(row.status)?.category === "Paused"
217 ),
218 },
219 },
220 selectable: true,
221 showSelectBanner: true,
222 selectBannerActions,
223 emptyState: {
224 title: __("No tickets found"),
225 icon: h(TicketIcon, {
226 class: "h-10 w-10",
227 }),
228 description:
229 activeView.value?.public || activeView.value?.pinned
230 ? __(
231 "No tickets found for this view. Try adjusting your filters or creating a new view."
232 )
233 : hasActiveFilters.value
234 ? __(
235 "No tickets found for the applied filters. Try adjusting or clearing your filters."
236 )
237 : undefined,
238 },
239 rowRoute: {
240 name: "TicketAgent",
241 prop: "ticketId",
242 },
243 hideColumnSetting: false,
244}));
245
246async function exportRows(
247 export_type: "CSV" | "Excel" = "Excel",
248 export_all: boolean = false
249) {
250 const list = listViewRef.value?.list;
251 if (!list) return;
252
253 const fields = JSON.stringify(list.data.columns.map((f) => f.key));
254 const order_by = list.params.order_by;
255
256 // Resolve `@me` filters to the current session user before export
257 const resolveAtMe = (entry: any) => {
258 if (Array.isArray(entry)) return entry.map(resolveAtMe);
259 if (entry === "@me") return userId;
260 if (entry === "%@me%") return `%${userId}%`;
261 return entry;
262 };
263 const conditions = normalizeFilters(list.params.filters).map(
264 ([field, operator, value]) => [field, operator, resolveAtMe(value)]
265 );
266 let pageLength: number;
267
268 if (export_all) {
269 pageLength = list.data.total_count;
270 } else {
271 pageLength = listSelections.value.size;
272 conditions.push(["name", "in", Array.from(listSelections.value)]);
273 }
274 const filters = JSON.stringify(conditions);
275
276 window.location.href = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type}&title=OD Ticket&doctype=OD Ticket&fields=${fields}&filters=${encodeURIComponent(
277 filters
278 )}&order_by=${order_by}&page_length=${pageLength}&start=0&view=Report&with_comment_count=1`;
279 reset();
280 showExportModal.value = false;
281}
282
283function reset(reload = false) {
284 listViewRef.value?.unselectAll();
285 listSelections.value?.clear();
286 if (reload) listViewRef.value.reload();
287}
288
289const slaStatusColorMap = {
290 Fulfilled: "gray",
291 Failed: "red",
292 "Resolution Due": "amber",
293 "First Response Due": "amber",
294 Paused: "blue",
295};
296
297let viewDialogConfig = reactive({
298 show: false,
299 view: {
300 label: "",
301 icon: "",
302 name: "",
303 },
304 mode: "create",
305});
306
307const dropdownOptions = computed(() => {
308 const items = [
309 {
310 group: __("Default Views"),
311 options: [
312 {
313 label: __("List View"),
314 icon: "lucide-text-align-justify",
315 onClick: () =>
316 router.push({
317 name: "TicketsAgent",
318 }),
319 },
320 ],
321 },
322 ];
323
324 // Saved Views
325 if (getCurrentUserViews.value?.length !== 0) {
326 items.push({
327 group: __("Saved Views"),
328 options: parseViews(getCurrentUserViews.value),
329 });
330 }
331 if (pinnedViews.value?.length !== 0) {
332 items.push({
333 group: __("Private Views"),
334 options: parseViews(pinnedViews.value),
335 });
336 }
337
338 const allPublicViews = [
339 ...(standardViews.value || []),
340 ...(publicViews.value || []),
341 ];
342
343 const uniquePublicViews = Array.from(
344 new Map(allPublicViews.map((v) => [v.name, v])).values()
345 );
346
347 items.push({
348 group: __("Public Views"),
349 options: parseViews(uniquePublicViews),
350 });
351
352 items.push({
353 group: __("Create View"),
354 hideLabel: true,
355 options: [
356 {
357 label: __("Create View"),
358 icon: "lucide-plus",
359 onClick: () => {
360 resetViewDialog(viewDialogConfig);
361 viewDialogConfig.show = true;
362 },
363 },
364 ],
365 });
366
367 return items;
368});
369
370function parseViews(views: View[]) {
371 return views?.map((view) => {
372 return {
373 ...view,
374 onClick: () => {
375 currentView.value = {
376 label: view.label,
377 icon: view.icon,
378 };
379 router.push({
380 name: view.route_name,
381 query: {
382 view: view.name,
383 },
384 });
385 },
386 };
387 });
388}
389
390function onViewModalUpdate(viewInfo: any, action: string) {
391 handleView(viewInfo, action, viewDialogConfig, () => listViewRef.value?.list);
392}
393
394onMounted(() => {
395 if (!route.query.view) {
396 currentView.value = {
397 label: __("List"),
398 icon: LucideAlignJustify,
399 };
400 }
401 $socket.on("opendesk:new-ticket", reloadList);
402});
403
404onUnmounted(() => {
405 $socket.off("opendesk:new-ticket", reloadList);
406});
407
408function reloadList() {
409 listViewRef.value?.reload();
410}
411
412usePageMeta(() => {
413 return {
414 title: __("Tickets"),
415 };
416});
417</script>
418