1<template>
2 <LayoutHeader>
3 <template #left-header>
4 <div class="flex max-w-[60%] flex-col truncate">
5 <Breadcrumbs :items="breadcrumbs" class="breadcrumbs -ms-0.5">
6 <template #prefix="{ item }">
7 <Icon
8 v-if="item.icon"
9 :icon="item.icon"
10 class="me-1 h-4 flex items-center justify-center self-center"
11 />
12 </template>
13 </Breadcrumbs>
14 </div>
15 </template>
16 <template #right-header>
17 <div class="flex gap-2 items-center">
18 <MultipleAvatar
19 :avatars="JSON.stringify(viewers)"
20 size="md"
21 :hide-name="true"
22 />
23 <!-- Custom Actions -->
24 <div v-if="normalActions.length" class="flex gap-2">
25 <Button v-for="action in normalActions" v-bind="action">
26 <template v-if="action.icon" #prefix>
27 <Icon :icon="action.icon" class="h-4 w-4" />
28 </template>
29 </Button>
30 </div>
31 <div v-if="groupedWithLabelActions.length">
32 <div v-for="g in groupedWithLabelActions" :key="g.label">
33 <Dropdown v-slot="{ open }" :options="g.action">
34 <Button :label="__(g.label)">
35 <template #suffix>
36 <component
37 :is="open ? LucideChevronUp : LucideChevronDown"
38 class="h-4"
39 />
40 </template>
41 </Button>
42 </Dropdown>
43 </div>
44 </div>
45 <!-- Status -->
46 <Dropdown :options="statusDropdown" align="end">
47 <template #default="{ open }">
48 <Button :label="__(ticket.doc.status)" ref="statusRef">
49 <template #prefix>
50 <IndicatorIcon
51 :class="
52 ticketStatusStore.getStatus(ticket.doc.status)?.parsed_color
53 "
54 />
55 </template>
56 </Button>
57 </template>
58 </Dropdown>
59 <!-- Core Actions + Custom Actions -->
60 <Dropdown
61 v-if="groupedActions.length"
62 :options="groupedActions"
63 align="end"
64 >
65 <Button
66 icon="lucide-more-horizontal"
67 :aria-label="__('More actions')"
68 />
69 </Dropdown>
70 </div>
71 </template>
72 </LayoutHeader>
73 <TicketMergeModal
74 :ticket="ticket.doc"
75 v-if="showMergeModal"
76 v-model="showMergeModal"
77 @update="ticket.reload()"
78 />
79 <TicketSubjectModal v-model="showSubjectDialog" />
80</template>
81
82<script setup lang="ts">
83import LucideChevronUp from "~icons/lucide/chevron-up";
84import LucideChevronDown from "~icons/lucide/chevron-down";
85import Icon from "@opendesk/shared/Icon.vue";
86import { MultipleAvatar } from "@/components";
87import LayoutHeader from "@/components/LayoutHeader.vue";
88import TicketMergeModal from "@/components/ticket/TicketMergeModal.vue";
89import { showMergeModal } from "@/pages/ticket/modalStates";
90import { setupCustomizations } from "@opendesk/shared/formScripts";
91import { useNotifyTicketUpdate } from "@/composables/realtime";
92import { useShortcut } from "@opendesk/shared/shortcuts";
93import { reloadTicketFeed } from "@/composables/useTicket";
94import { useView } from "@/composables/useView";
95import { useAuthStore } from "@/stores/auth";
96import { globalStore } from "@/stores/globalStore";
97import { useTicketStatusStore } from "@/stores/ticketStatus";
98import { __ } from "@/translation";
99import { CustomizationSymbol, TicketSymbol, View } from "@/types";
100import { ODTicketStatus } from "@/types/doctypes";
101import { getIcon } from "@/utils";
102import {
103 Breadcrumbs,
104 Button,
105 call,
106 createResource,
107 Dropdown,
108 toast,
109} from "frappe-ui";
110import {
111 computed,
112 ComputedRef,
113 h,
114 inject,
115 onMounted,
116 PropType,
117 ref,
118 useTemplateRef,
119 watchEffect,
120} from "vue";
121import { useRoute, useRouter } from "vue-router";
122import LucideMerge from "~icons/lucide/merge";
123import { IndicatorIcon } from "../icons";
124import TicketSubjectModal from "./TicketSubjectModal.vue";
125const { isAdmin } = useAuthStore();
126const { $dialog } = globalStore();
127
128defineProps({
129 viewers: {
130 type: Array as PropType<string[]>,
131 required: true,
132 },
133});
134
135const route = useRoute();
136const router = useRouter();
137const { findView } = useView("OD Ticket");
138const ticketStatusStore = useTicketStatusStore();
139
140const ticket = inject(TicketSymbol)!;
141const customizations = inject(CustomizationSymbol)!;
142const showSubjectDialog = ref(false);
143
144const { notifyTicketUpdate } = useNotifyTicketUpdate(ticket.value?.name);
145const statusDropdown = computed(() => {
146 const statuses =
147 ticketStatusStore.statuses.data?.filter((s) => s.enabled) || [];
148 return statuses.map((o: ODTicketStatus) => ({
149 label: __(o.label_agent),
150 value: o.label_agent,
151 onClick: () => {
152 notifyTicketUpdate("Status", o.label_agent);
153 if (ticket.value.doc.status === o.label_agent) return;
154 ticket.value.setValue.submit(
155 { status: o.label_agent },
156 { onSuccess: () => reloadTicketFeed(ticket.value.name) }
157 );
158 },
159 icon: () =>
160 h(IndicatorIcon, {
161 class: o.parsed_color,
162 }),
163 }));
164});
165const breadcrumbs = computed(() => {
166 let items = [{ label: __("Tickets"), route: { name: "TicketsAgent" } }];
167 if (route.query.view) {
168 const currView: ComputedRef<View> = findView(route.query.view as string);
169 if (currView) {
170 items.push({
171 label: __(currView.value?.label),
172 icon: getIcon(currView.value?.icon),
173 route: { name: "TicketsAgent", query: { view: currView.value?.name } },
174 });
175 }
176 }
177 items.push({
178 label: ticket.value.doc?.subject,
179 onClick: () => {
180 showSubjectDialog.value = true;
181 },
182 });
183 return items;
184});
185
186function updateField(fieldname: string, value: string, callback = () => {}) {
187 const doc = ticket.value;
188 doc.setValue.submit({
189 [fieldname]: value,
190 });
191 callback();
192}
193
194function handleDeleteTicket() {
195 $dialog({
196 title: __(`Delete ticket #${ticket?.value?.name}`),
197 message: __(
198 "Are you sure you want to delete this ticket? This is an irreversible action and cannot be undone."
199 ),
200 actions: [
201 {
202 label: __("Delete"),
203 theme: "red",
204 iconLeft: "lucide-trash-2",
205 variant: "solid",
206 onClick({ close }) {
207 call("opendesk.api.ticket.delete_ticket", {
208 name: ticket?.value?.doc.name,
209 })
210 .then(() => {
211 toast.success(__("Ticket deleted successfully."));
212 router.push({ name: "TicketsAgent" });
213 })
214 .catch((err: any) => {
215 toast.error(err || __("Failed to delete ticket."));
216 });
217 close();
218 },
219 },
220 ],
221 });
222}
223
224const ticketCount = createResource({
225 url: "frappe.client.get_count",
226 makeParams: () => ({
227 doctype: "OD Ticket",
228 filters: {
229 status_category: ["!=", "Resolved"],
230 is_merged: 0,
231 },
232 }),
233 auto: true,
234});
235const showMergeOption = computed(() => {
236 return (
237 !ticket?.value?.doc?.is_merged &&
238 ["Open", "Paused"].includes(ticket?.value?.doc?.status_category) &&
239 ticketCount.data > 1
240 );
241});
242const defaultActions = computed(() => {
243 let items = [];
244
245 if (showMergeOption.value) {
246 items.push({
247 label: __("Merge Ticket"),
248 icon: LucideMerge,
249 condition: () => !ticket.value.doc.is_merged,
250 onClick: () => (showMergeModal.value = true),
251 });
252 }
253
254 return [
255 {
256 group: __("Default actions"),
257 hideLabel: true,
258 options: items,
259 },
260 ];
261});
262
263const deleteAction = computed(() => {
264 if (!isAdmin) return [];
265 return [
266 {
267 group: __("Default actions"),
268 hideLabel: true,
269 options: [
270 {
271 label: __("Delete"),
272 icon: "lucide-trash-2",
273 theme: "red",
274 onClick: handleDeleteTicket,
275 },
276 ],
277 },
278 ];
279});
280
281const actions = ref<any[]>([]);
282const normalActions = computed(() => {
283 return actions.value.filter((action) => !action.group);
284});
285
286const groupedWithLabelActions = computed(() => {
287 let _actions = [];
288
289 actions.value
290 .filter((action) => action.buttonLabel && action.group)
291 .forEach((action) => {
292 let groupIndex = _actions.findIndex(
293 (a) => a.label === action.buttonLabel
294 );
295 if (groupIndex > -1) {
296 _actions[groupIndex].action.push(action);
297 } else {
298 _actions.push({
299 label: __(action.buttonLabel),
300 action: [action],
301 });
302 }
303 });
304 return _actions;
305});
306
307const groupedActions = computed(() => {
308 let _actions = [];
309 _actions = _actions.concat(defaultActions.value);
310 _actions = _actions.concat(
311 actions.value.filter((action) => action.group && !__(action.buttonLabel))
312 );
313 _actions = _actions.concat(deleteAction.value);
314 // Drop empty groups so the kebab hides when there is nothing to show. Form
315 // script actions are flat rows with no `options`, so they must survive.
316 return _actions.filter((action) => !action.options || action.options.length);
317});
318
319const customizationCtx = computed(() => ({
320 doc: ticket?.value?.doc,
321 call,
322 router,
323 $dialog: globalStore().$dialog,
324 updateField,
325}));
326
327// to manage the correct customization context for actions, happens because of navigation between tickets using buttons
328watchEffect(async () => {
329 if (customizations.value?.data) {
330 await setupCustomizations(
331 customizations.value.data,
332 customizationCtx.value
333 );
334
335 actions.value = [...(customizations.value?.data?._customActions || [])];
336 }
337});
338
339const statusRef = useTemplateRef("statusRef");
340
341onMounted(() => {
342 useShortcut("s", () => {
343 statusRef.value?.$el?.click();
344 });
345});
346</script>
347
348<style>
349.breadcrumbs button {
350 background-color: inherit !important;
351 &:hover,
352 &:focus {
353 background-color: inherit !important;
354 }
355}
356</style>
357