1<template>
2 <div class="flex flex-col">
3 <LayoutHeader v-if="ticket.doc?.name">
4 <template #left-header>
5 <div class="max-w-[60vw]">
6 <Breadcrumbs :items="breadcrumbs" />
7 </div>
8 </template>
9 <template #right-header>
10 <div class="absolute end-0 pe-2">
11 <Dropdown :options="dropdownOptions">
12 <template #default="{ open }">
13 <Button :label="ticket.doc.status">
14 <template #prefix>
15 <IndicatorIcon
16 :class="
17 ticketStatusStore.getStatus(ticket.doc.status)
18 ?.parsed_color
19 "
20 />
21 </template>
22 <template #suffix>
23 <component
24 :is="open ? LucideChevronUp : LucideChevronDown"
25 class="h-4"
26 />
27 </template>
28 </Button>
29 </template>
30 </Dropdown>
31 </div>
32 </template>
33 </LayoutHeader>
34 <header
35 class="flex h-12 items-center justify-between gap-2 py-[7px] px-3 border-b"
36 v-if="ticket.doc?.name"
37 >
38 <!-- left side -->
39 <div class="flex min-w-0 flex-1 items-center gap-2">
40 <!-- the width lives on the wrapper: a Combobox with its own #trigger
41 slot drops the class it is handed -->
42 <div v-if="showTeamPicker" class="min-w-0 flex-1">
43 <Link
44 doctype="OD Team"
45 :hide-clear-button="true"
46 :model-value="ticket.doc.agent_group"
47 @update:model-value="(val) => updateField('agent_group', val)"
48 >
49 <!-- Same trigger styling as AssignTo so the header controls match -->
50 <template #trigger>
51 <Button
52 variant="outline"
53 class="!flex !justify-start w-full active:!bg-inherit hover:shadow-sm [&>span]:w-full"
54 >
55 <div class="flex items-center min-h-5 gap-2 w-full">
56 <span
57 class="truncate"
58 :class="
59 ticket.doc.agent_group
60 ? 'text-ink-gray-7'
61 : 'text-ink-gray-5'
62 "
63 >
64 {{ ticket.doc.agent_group || __("Team") }}
65 </span>
66 </div>
67 <template #suffix>
68 <LucideChevronDown class="h-4 w-4 ms-auto text-ink-gray-5" />
69 </template>
70 </Button>
71 </template>
72 </Link>
73 </div>
74 <div class="min-w-0 flex-1">
75 <AssignTo :hide-label="true" />
76 </div>
77 </div>
78 <!-- right side -->
79 <div class="flex items-center gap-2">
80 <CustomActions
81 v-if="mobileCustomActions.length"
82 :actions="mobileCustomActions"
83 />
84 </div>
85 </header>
86 <div
87 v-if="ticket.doc?.name"
88 :key="ticketId"
89 class="flex min-h-0 flex-1 overflow-x-hidden"
90 >
91 <div class="flex min-h-0 flex-1 flex-col overflow-x-hidden">
92 <div class="flex min-h-0 flex-1 flex-col">
93 <Tabs
94 :modelValue="activeTab"
95 @update:modelValue="changeTabTo"
96 class="flex flex-col flex-1 overflow-hidden"
97 >
98 <div class="flex shrink-0">
99 <!-- Scroll here so the active underline isn't clipped. -->
100 <div class="min-w-0 flex-1 overflow-x-auto hide-scrollbar">
101 <TabList size="md" class="w-max min-w-full px-3 py-1.5">
102 <TabTrigger
103 v-for="tab in visibleTabs"
104 :key="tab.value"
105 :value="tab.value"
106 :label="tab.label"
107 :icon-left="tab.iconLeft"
108 />
109 </TabList>
110 </div>
111 <!-- own border, so the tab underline runs on beneath it -->
112 <div
113 v-if="activeTab === 'call'"
114 class="flex items-center border-b border-outline-gray-1 pe-3"
115 >
116 <TicketCallActions />
117 </div>
118 </div>
119 <TabPanel
120 v-for="tab in visibleTabs"
121 :key="tab.value"
122 :value="tab.value"
123 class="min-h-0 flex-1 flex-col overflow-auto data-[state=active]:flex"
124 >
125 <div v-if="tab.value === 'details'">
126 <!-- ticket contact info -->
127 <TicketAgentContact
128 v-if="contact.data"
129 :contact="contact.data"
130 :ticketId="ticket.doc?.name"
131 @email:open="communicationAreaRef.toggleEmailBox()"
132 />
133 <!-- feedback component -->
134 <TicketFeedback
135 v-if="ticket.doc?.feedback_rating"
136 class="border-b px-6 py-3 text-base text-ink-gray-5"
137 :ticket="ticket.doc"
138 />
139 <!-- SLA Section, hidden when no policy is attached -->
140 <template v-if="ticket.doc?.sla">
141 <h3 class="px-6 pt-3 text-base-semibold">
142 {{ __("SLA") }}
143 </h3>
144 <div class="px-6 py-3">
145 <TicketSLA />
146 </div>
147 </template>
148 <div
149 class="flex items-center border-b px-6 py-3 text-base leading-5"
150 >
151 <div class="w-[126px] text-sm text-ink-gray-5">
152 {{ __("Source") }}
153 </div>
154 <div>
155 {{
156 ticket.doc?.via_customer_portal
157 ? __("Portal")
158 : __("Mail")
159 }}
160 </div>
161 </div>
162 <!-- Ticket Fields -->
163 <h3 class="px-6 pt-3 text-base-semibold">
164 {{ __("Details") }}
165 </h3>
166 <TicketAgentFields
167 :ticket="ticketWithFields"
168 @update="
169 ({ field, value }) =>
170 ticket.setValue.submit({ [field]: value })
171 "
172 class="!border-0"
173 />
174 </div>
175
176 <!-- Rest Activities -->
177 <TicketTimeline
178 v-else
179 ref="timelineRef"
180 :ticket-id="String(ticket.doc?.name)"
181 :tab="tab.value"
182 :tab-label="tab.label"
183 @email:reply="(e) => communicationAreaRef?.replyToEmail(e)"
184 />
185 </TabPanel>
186 </Tabs>
187 <CommunicationArea
188 class="bg-surface-base"
189 ref="communicationAreaRef"
190 v-model="ticket.doc"
191 :ticketId="ticket.doc?.name"
192 :to-emails="[ticket.doc.raised_by]"
193 :cc-emails="[]"
194 :bcc-emails="[]"
195 :key="ticket.doc?.name"
196 @update="
197 () => {
198 reloadTicket(props.ticketId);
199 timelineRef[0]?.reload();
200 }
201 "
202 />
203 </div>
204 </div>
205 </div>
206
207 <Dialog v-model:open="showSubjectDialog">
208 <template #title>
209 <h3>{{ __("Rename") }}</h3>
210 </template>
211 <template #default>
212 <FormControl
213 v-model="subjectInput"
214 :type="'text'"
215 size="sm"
216 variant="subtle"
217 :disabled="false"
218 :label="__('New Subject')"
219 />
220 </template>
221 <template #actions>
222 <Button
223 variant="solid"
224 :disabled="!subjectInput"
225 :loading="ticket.setValue.loading"
226 @click="
227 () => {
228 ticket.setValue.submit({ subject: subjectInput });
229 showSubjectDialog = false;
230 }
231 "
232 >
233 {{ __("Confirm") }}
234 </Button>
235 <Button class="ms-2" @click="showSubjectDialog = false">
236 {{ __("Close") }}
237 </Button>
238 </template>
239 </Dialog>
240 <SetContactPhoneModal
241 v-model="showPhoneModal"
242 :name="contact.data?.name"
243 @onUpdate="() => reloadTicket(props.ticketId)"
244 />
245 </div>
246</template>
247
248<script setup lang="ts">
249import LucideChevronUp from "~icons/lucide/chevron-up";
250import LucideChevronDown from "~icons/lucide/chevron-down";
251import { __ } from "@/translation";
252import {
253 Breadcrumbs,
254 call,
255 createResource,
256 Dialog,
257 Dropdown,
258 FormControl,
259 TabList,
260 TabPanel,
261 Tabs,
262 TabTrigger,
263} from "frappe-ui";
264import {
265 computed,
266 ComputedRef,
267 h,
268 onMounted,
269 onUnmounted,
270 PropType,
271 provide,
272 ref,
273 watch,
274 watchEffect,
275} from "vue";
276
277import { CommunicationArea, LayoutHeader, Link } from "@/components";
278import {
279 ActivityIcon,
280 CommentIcon,
281 DetailsIcon,
282 EmailIcon,
283 IndicatorIcon,
284 PhoneIcon,
285} from "@/components/icons";
286import TicketCallActions from "@/components/ticket/TicketCallActions.vue";
287import TicketTimeline from "@/components/ticket-agent/timeline/TicketTimeline.vue";
288
289// Lives in studio/ so Studio pages can use it as a block too.
290import CustomActions from "../../../../studio/opendesk/components/header/CustomActions.vue";
291import AssignTo from "@/components/ticket-agent/AssignTo.vue";
292import SetContactPhoneModal from "@/components/ticket/SetContactPhoneModal.vue";
293import TicketSLA from "@/components/ticket-agent/TicketSLA.vue";
294import TicketAgentFields from "@/components/ticket/TicketAgentFields.vue";
295import { parseField } from "@/composables/fieldDependsOn";
296import { setupCustomizations } from "@opendesk/shared/formScripts";
297import { useScreenSize } from "@/composables/screen";
298import { useActiveTabManager } from "@/composables/useActiveTabManager";
299import {
300 reloadTicket,
301 revalidateTicket,
302 useTicket,
303} from "@/composables/useTicket";
304import { globalStore } from "@/stores/globalStore";
305import { getMeta } from "@/stores/meta";
306import { useTelephonyStore } from "@/stores/telephony";
307import { useTicketStatusStore } from "@/stores/ticketStatus";
308import {
309 AssigneeSymbol,
310 Customizations,
311 CustomizationSymbol,
312 RecentSimilarTicketsSymbol,
313 Resource,
314 TabObject,
315 TicketContactSymbol,
316 TicketSymbol,
317} from "@/types";
318import { ODTicketStatus } from "@/types/doctypes";
319import { storeToRefs } from "pinia";
320import { useRouter } from "vue-router";
321
322const telephonyStore = useTelephonyStore();
323const { isCallingEnabled } = storeToRefs(telephonyStore);
324
325const ticketStatusStore = useTicketStatusStore();
326const router = useRouter();
327const { $dialog } = globalStore();
328
329const timelineRef = ref<InstanceType<typeof TicketTimeline>[]>([]);
330const communicationAreaRef = ref<InstanceType<typeof CommunicationArea> | null>(
331 null
332);
333
334const subjectInput = ref(null);
335const showPhoneModal = ref(false);
336const customActions = ref([]);
337
338type ticketId = string;
339
340const props = defineProps({
341 ticketId: {
342 type: [String, Number] as PropType<ticketId>,
343 required: true,
344 },
345});
346
347const ticketComposable = computed(() => useTicket(props.ticketId));
348const ticket = computed(() => ticketComposable.value.ticket);
349const assignees = computed(() => ticketComposable.value.assignees);
350const contact = computed(() => ticketComposable.value.contact);
351
352const customizations: Resource<Customizations> = createResource({
353 url: "opendesk.opendesk.doctype.od_ticket.api.get_ticket_customizations",
354 cache: ["OD Ticket", "customizations"],
355 auto: true,
356});
357
358// Build fields from getMeta + customizations (same as TicketDetailsTab)
359const { getField, getFields } = getMeta("OD Ticket");
360
361function updateField(name: string, value: string) {
362 ticket.value.setValue.submit({ [name]: value });
363}
364
365const customizationCtx = computed(() => ({
366 doc: ticket.value?.doc,
367 call,
368 router,
369 $dialog,
370 updateField,
371}));
372
373watchEffect(async () => {
374 if (customizations.data) {
375 await setupCustomizations(customizations.data, customizationCtx.value);
376 customActions.value = [...(customizations.data?._customActions || [])];
377 }
378});
379
380// On mobile, collapse all custom actions into a single three-dot group
381const mobileCustomActions = computed(() => {
382 if (!customActions.value.length) return [];
383
384 const items: { label: string; onClick: () => void }[] = [];
385
386 for (const action of customActions.value) {
387 if (action.group) {
388 // Grouped action (with or without buttonLabel) — flatten its options
389 for (const item of action.options || []) {
390 items.push({ label: item.label, onClick: item.onClick });
391 }
392 } else {
393 // Normal standalone button
394 items.push({ label: action.label, onClick: action.onClick });
395 }
396 }
397
398 if (!items.length) return [];
399
400 return [{ group: "Actions", hideLabel: true, options: items }];
401});
402
403const showTeamPicker = computed(() =>
404 (customizations.data?.fields || []).some((f) => f.fieldname === "agent_group")
405);
406
407const ticketFields = computed(() => {
408 if (!customizations.data || !ticket.value.doc) return [];
409 const fieldsMeta = getFields();
410 if (!fieldsMeta || fieldsMeta.length === 0) return [];
411
412 const coreFieldNames = [
413 "ticket_type",
414 "agent_group",
415 "priority",
416 "customer",
417 "subject",
418 "status",
419 ];
420 let custom_fields = customizations.data?.fields || [];
421 custom_fields = custom_fields.filter(
422 (f) => !coreFieldNames.includes(f.fieldname)
423 );
424
425 return custom_fields
426 .map((f) => {
427 let fieldMeta = getField(f.fieldname);
428 if (!fieldMeta) return null;
429 fieldMeta = parseField(fieldMeta, ticket.value.doc);
430 return {
431 label: fieldMeta?.label || f.fieldname,
432 fieldname: f.fieldname,
433 fieldtype: fieldMeta?.fieldtype,
434 options: fieldMeta?.options || "",
435 placeholder:
436 f.placeholder || `Enter ${fieldMeta?.label || f.fieldname}`,
437 readonly: Boolean(fieldMeta.read_only),
438 disabled: Boolean(fieldMeta.read_only),
439 url_method: f.url_method || "",
440 required: f.required || fieldMeta?.reqd || false,
441 visible:
442 fieldMeta.display_via_depends_on &&
443 !fieldMeta.hidden &&
444 (!!ticket.value.doc[f.fieldname] || !fieldMeta.read_only),
445 };
446 })
447 .filter(Boolean);
448});
449
450// Merged ticket doc with computed fields for TicketAgentFields
451const ticketWithFields = computed(() => ({
452 ...ticket.value.doc,
453 fields: ticketFields.value,
454}));
455
456provide(TicketSymbol, ticket);
457provide(
458 AssigneeSymbol,
459 computed(() => ticketComposable.value.assignees)
460);
461provide(
462 TicketContactSymbol,
463 computed(() => ticketComposable.value.contact)
464);
465provide(
466 CustomizationSymbol,
467 computed(() => customizations)
468);
469provide(
470 RecentSimilarTicketsSymbol,
471 computed(() => ticketComposable.value.recentSimilarTickets)
472);
473provide("communicationArea", communicationAreaRef);
474provide("makeCall", () => {
475 if (!contact.value.data?.mobile_no && !contact.value.data?.phone) {
476 showPhoneModal.value = true;
477 return;
478 }
479 telephonyStore.makeCall({
480 number: contact.value.data?.phone || contact.value.data?.mobile_no,
481 doctype: "OD Ticket",
482 docname: props.ticketId,
483 });
484});
485provide("ticketId", props.ticketId);
486provide("refreshTicket", () => reloadTicket(props.ticketId));
487provide("onCallEnded", () => reloadTicket(props.ticketId));
488
489const { isMobileView } = useScreenSize();
490
491const showSubjectDialog = ref(false);
492
493const breadcrumbs = computed(() => {
494 let items = [{ label: __("Tickets"), route: { name: "TicketsAgent" } }];
495 items.push({
496 label: ticket.value.doc?.subject,
497 route: { name: "TicketAgent" },
498 });
499 return items;
500});
501
502const dropdownOptions = computed(() =>
503 ticketStatusStore.statuses.data
504 ?.filter((o: ODTicketStatus) => o.enabled)
505 .map((o: ODTicketStatus) => ({
506 label: o.label_agent,
507 value: o.label_agent,
508 onClick: () => ticket.value.setValue.submit({ status: o.label_agent }),
509 icon: () =>
510 h(IndicatorIcon, {
511 class: o.parsed_color,
512 }),
513 }))
514);
515
516const tabs: ComputedRef<TabObject[]> = computed(() => {
517 const _tabs = [
518 {
519 value: "details",
520 label: __("Details"),
521 iconLeft: DetailsIcon,
522 condition: () => isMobileView.value,
523 },
524 {
525 value: "activity",
526 label: __("Activity"),
527 iconLeft: ActivityIcon,
528 },
529 {
530 value: "email",
531 label: __("Emails"),
532 iconLeft: EmailIcon,
533 },
534 {
535 value: "comment",
536 label: __("Comments"),
537 iconLeft: CommentIcon,
538 },
539 ];
540
541 if (isCallingEnabled.value) {
542 _tabs.push({
543 value: "call",
544 label: __("Calls"),
545 iconLeft: PhoneIcon,
546 });
547 }
548 return _tabs;
549});
550
551const visibleTabs = computed(() =>
552 tabs.value.filter((tab) => !tab.condition || tab.condition())
553);
554
555const { activeTab, changeTabTo } = useActiveTabManager(tabs);
556
557onMounted(() => {
558 document.title = props.ticketId;
559 // Revisiting a ticket: show the cached conversation immediately and refresh it
560 // in place (mobile has no live socket refresh to keep the cache current).
561 revalidateTicket(props.ticketId);
562});
563
564watch(ticket, (ticket) => ticket.markSeen.reload(), { immediate: true });
565
566onUnmounted(() => {
567 document.title = "Opendesk";
568});
569</script>
570<style scoped>
571:deep(.breadcrumb-item span),
572:deep(a span) {
573 overflow: hidden;
574 display: -webkit-box;
575 -webkit-line-clamp: 1;
576 -webkit-box-orient: vertical;
577 white-space: normal !important;
578}
579</style>
580