1<template>
2 <div v-if="ticket.doc?.name && feedReady" :key="ticketId" class="flex-1">
3 <TicketHeader :viewers="viewers" />
4 <div class="h-full flex overflow-hidden">
5 <div class="flex-1 flex flex-col overflow-hidden">
6 <!-- Tabs & Communication Area -->
7 <TicketActivityPanel />
8 </div>
9
10 <!-- Sidepanel with Resizer -->
11 <TicketSidebar />
12 </div>
13 <SetContactPhoneModal
14 v-if="ticket.doc.contact"
15 v-model="showPhoneModal"
16 :name="ticket.doc?.contact"
17 @onUpdate="ticket.reload"
18 />
19 </div>
20 <div
21 v-else-if="ticket.doc?.name || (!ticket.doc && !ticket.get?.error)"
22 class="grid h-full place-items-center"
23 >
24 <LoadingIndicator class="size-6 text-ink-gray-4" />
25 </div>
26
27 <div v-else class="grid h-full place-items-center px-4 py-20 text-center">
28 <div class="space-y-2">
29 <div class="flex justify-center items-center mx-auto">
30 <TicketIcon class="size-10 text-ink-gray-4" />
31 </div>
32 <div class="text-md-medium text-ink-gray-8">
33 {{ __("Ticket not found") }}
34 </div>
35 <div class="text-center text-p-base text-ink-gray-6 mt-1">
36 {{
37 __("You don't have access to this ticket, or it no longer exists.")
38 }}
39 </div>
40 <Button :route="{ name: 'TicketsAgent' }" variant="subtle">
41 <template #prefix><LucideArrowLeft class="size-4" /></template>
42 {{ __("Back to Tickets") }}
43 </Button>
44 </div>
45 </div>
46</template>
47
48<script setup lang="ts">
49import LucideArrowLeft from "~icons/lucide/arrow-left";
50import { recordTicketVisit } from "@/components/command-palette/recentTickets";
51import TicketIcon from "@/components/icons/TicketIcon.vue";
52import TicketActivityPanel from "@/components/ticket-agent/TicketActivityPanel.vue";
53import { SHARED_VISIBLE_TYPES } from "@/components/ticket-agent/timeline/TicketTimeline.vue";
54import { useActivityTimeline } from "@framework/ui/ActivityTimeline";
55import TicketHeader from "@/components/ticket-agent/TicketHeader.vue";
56import TicketSidebar from "@/components/ticket-agent/TicketSidebar.vue";
57import SetContactPhoneModal from "@/components/ticket/SetContactPhoneModal.vue";
58import { useActiveViewers } from "@/composables/realtime";
59import {
60 reloadTicket,
61 revalidateTicket,
62 useTicket,
63} from "@/composables/useTicket";
64import {
65 ticketsToNavigate,
66 useTicketNavigation,
67} from "@/composables/useTicketNavigation";
68import { globalStore } from "@/stores/globalStore";
69import { useTelephonyStore } from "@/stores/telephony";
70import {
71 AssigneeSymbol,
72 Customizations,
73 CustomizationSymbol,
74 RecentSimilarTicketsSymbol,
75 Resource,
76 TicketContactSymbol,
77 TicketSymbol,
78} from "@/types";
79import {
80 createResource,
81 LoadingIndicator,
82 toast,
83 usePageMeta,
84} from "frappe-ui";
85import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
86import { useRoute } from "vue-router";
87import { showCommentBox, showEmailBox } from "./modalStates";
88
89const telephonyStore = useTelephonyStore();
90const { $socket } = globalStore();
91
92const props = defineProps({
93 ticketId: {
94 type: String,
95 required: true,
96 },
97});
98const route = useRoute();
99const showPhoneModal = ref(false);
100
101// Started here, not in the timeline, so it runs alongside the doc fetch and both
102// sit behind one spinner. Every tab shares this one store.
103const { loading: feedLoading } = useActivityTimeline(
104 "OD Ticket",
105 props.ticketId,
106 SHARED_VISIBLE_TYPES
107);
108// latched: a doc_update reloads the feed, and that must not blank the page
109const feedReady = ref(false);
110
111useTicketNavigation();
112
113const ticketComposable = computed(() => useTicket(props.ticketId));
114const ticket = computed(() => ticketComposable.value.ticket);
115const customizations: Resource<Customizations> = createResource({
116 url: "opendesk.opendesk.doctype.od_ticket.api.get_ticket_customizations",
117 cache: ["OD Ticket", "customizations"],
118 auto: true,
119});
120
121provide(TicketSymbol, ticket);
122
123provide(
124 AssigneeSymbol,
125 computed(() => ticketComposable.value.assignees)
126);
127provide(
128 TicketContactSymbol,
129 computed(() => ticketComposable.value.contact)
130);
131provide(
132 CustomizationSymbol,
133 computed(() => customizations)
134);
135provide(
136 RecentSimilarTicketsSymbol,
137 computed(() => ticketComposable.value.recentSimilarTickets)
138);
139provide("makeCall", () => {
140 if (
141 !ticketComposable.value.contact.data?.mobile_no &&
142 !ticketComposable.value.contact.data?.phone
143 ) {
144 showPhoneModal.value = true;
145 return;
146 }
147 telephonyStore.makeCall({
148 number:
149 ticketComposable.value.contact.data?.phone ||
150 ticketComposable.value.contact.data?.mobile_no,
151 doctype: "OD Ticket",
152 docname: props.ticketId,
153 });
154});
155provide("refreshTicket", () => reloadTicket(props.ticketId));
156provide("onCallEnded", () => reloadTicket(props.ticketId));
157
158const viewerComposable = computed(() => useActiveViewers(ticket.value.name));
159const viewers = computed(
160 () => viewerComposable.value.currentViewers[props.ticketId] || []
161);
162const { startViewing, stopViewing } = viewerComposable.value;
163
164// handling for faster navigation between tickets
165watch(
166 () => route.params.ticketId,
167 (newTicketId, oldTicketId) => {
168 if (newTicketId === oldTicketId) return;
169
170 if (oldTicketId) stopViewing(oldTicketId as string);
171 startViewing(newTicketId as string);
172
173 // Switching to an already-visited ticket: show its cached conversation and
174 // refresh it in the background in case it changed while we were elsewhere.
175 if (oldTicketId) revalidateTicket(newTicketId as string);
176 },
177 { immediate: true }
178);
179
180watch(ticket, (ticket) => ticket.markSeen.reload(), { immediate: true });
181
182// immediate so a store fetched earlier in the session opens the gate right away
183watch(
184 feedLoading,
185 (loading) => {
186 if (!loading) feedReady.value = true;
187 },
188 { immediate: true }
189);
190
191// Feeds the command palette's "Recent" list, which is what an empty Cmd+K shows.
192watch(
193 () => ticket.value?.doc?.subject,
194 (subject) => subject && recordTicketVisit(props.ticketId, subject),
195 { immediate: true }
196);
197
198type TicketUpdateData = {
199 ticket_id: string;
200 user: string;
201 field: string;
202 value: string;
203};
204
205onMounted(() => {
206 // Revisiting a ticket: show the cached conversation immediately and refresh it
207 // in place, since a reply may have arrived while the socket listener was off.
208 revalidateTicket(props.ticketId);
209
210 ticketsToNavigate.update({
211 params: {
212 ticket: props.ticketId,
213 current_view: route.query.view as string,
214 },
215 });
216 ticketsToNavigate.reload();
217
218 $socket.on("ticket_update", onTicketUpdate);
219 $socket.on("opendesk:ticket-update", onOpendeskTicketUpdate);
220});
221
222onBeforeUnmount(() => {
223 stopViewing(props.ticketId);
224 showEmailBox.value = false;
225 showCommentBox.value = false;
226
227 $socket.off("ticket_update", onTicketUpdate);
228 $socket.off("opendesk:ticket-update", onOpendeskTicketUpdate);
229});
230
231function onTicketUpdate(data: TicketUpdateData) {
232 if (data.ticket_id === ticket.value?.name) {
233 toast.info(`User ${data.user} updated ${data.field} to ${data.value}`);
234 }
235}
236
237function onOpendeskTicketUpdate(data: { ticket_id: string }) {
238 if (data.ticket_id == props.ticketId) {
239 reloadTicket(props.ticketId);
240 }
241}
242usePageMeta(() => {
243 if (!ticket.value?.doc?.name) {
244 return { title: props.ticketId };
245 }
246
247 return {
248 title: props.ticketId + " - " + (ticket.value?.doc?.subject ?? ""),
249 };
250});
251</script>
252
253<style>
254.breadcrumbs button {
255 background-color: inherit !important;
256 &:hover,
257 &:focus {
258 background-color: inherit !important;
259 }
260}
261</style>
262