1<template>
2 <div class="flex h-full flex-col">
3 <!-- Contact header + SLA, pinned above the scrollable sections. Uniform
4 padding on the wrapper; the gap between them comes from space-y, not
5 per-element bottom padding. -->
6 <div class="shrink-0 space-y-4 p-4">
7 <TicketContact />
8 <TicketSLA />
9 </div>
10
11 <!-- Scrollable sections: Key Info + Ticket Info + Recent / Similar Tickets -->
12 <div
13 ref="scrollAreaRef"
14 class="min-h-0 flex-1 overflow-y-auto border-t"
15 @scroll.passive="trimScrollSpacer"
16 >
17 <div ref="scrollContentRef" class="divide-y-[1px]">
18 <!-- Feedback, only once the contact has rated the ticket -->
19 <div v-if="ticket.doc?.feedback_rating">
20 <TicketFeedback
21 :rating="ticket.doc.feedback_rating"
22 :feedback="ticket.doc.feedback"
23 :comment="ticket.doc.feedback_extra"
24 v-model:opened="openedSections.feedback"
25 />
26 </div>
27
28 <!-- Key Info (core fields) -->
29 <Section
30 :label="__('Overview')"
31 v-model:opened="openedSections.keyInfo"
32 >
33 <div class="space-y-2.5 pb-3 pt-0.5">
34 <!-- Assignee -->
35 <div class="flex items-center gap-2 leading-5">
36 <FieldLabel label="Assignee" />
37 <div
38 class="-m-1 min-h-[28px] min-w-0 flex-1 items-center overflow-hidden p-1"
39 >
40 <AssignTo hide-label ghost />
41 </div>
42 </div>
43 <!-- Core fields -->
44 <template v-for="field in coreFields">
45 <TicketField
46 v-if="field?.visible"
47 :key="field.fieldname"
48 :ref="(el) => setFieldRef(field.fieldname, el)"
49 :field="field"
50 :value="field.value"
51 @change="
52 ({ fieldname, value }) =>
53 handleFieldUpdate(fieldname, value, true)
54 "
55 />
56 </template>
57
58 <!-- Tags -->
59 <!-- pt-0.5: chips are 24px vs the 20px avatars/text above, so they
60 sit 2px higher and the gap to the Assignee row reads tighter
61 than the field-to-field gap. Nudge the row down to even it. -->
62 <div class="flex items-start gap-2 pt-1">
63 <FieldLabel label="Tags" class="pt-0.5" />
64 <!-- 9px = the Link triggers' 8px padding + 1px border, so chips
65 and the add button start on the same column as the values -->
66 <div class="min-w-0 flex-1 py-0.5 ps-[9px]">
67 <Tags
68 doctype="OD Ticket"
69 :name="ticket.doc?.name"
70 :tags="ticket.doc?._user_tags"
71 />
72 </div>
73 </div>
74 </div>
75 </Section>
76
77 <!-- Ticket Info (custom fields) -->
78 <div v-if="Boolean(customFields.length)">
79 <Section
80 :label="__('More Details')"
81 v-model:opened="openedSections.ticketInfo"
82 >
83 <div class="space-y-2.5 pb-2 pt-0.5">
84 <template v-for="field in customFields">
85 <TicketField
86 v-if="field.visible"
87 :key="field.fieldname"
88 :field="field"
89 :value="field.value"
90 @change="
91 ({ fieldname, value }) =>
92 handleFieldUpdate(fieldname, value)
93 "
94 />
95 </template>
96 </div>
97 </Section>
98 </div>
99
100 <!-- Recent / Similar Tickets -->
101 <template v-if="showRecentSimilarTickets">
102 <div v-for="section in sections" :key="section.label">
103 <!-- rows carry py-3 for their hover background, so the header and
104 list give that space back to match the other sections -->
105 <Section
106 :label="section.label"
107 :tooltip="section.tooltipMessage"
108 :hideLabel="section.hideLabel"
109 header-class="pb-2.5"
110 v-model:opened="openedSections[section.key]"
111 >
112 <ul class="divide-y divide-outline-gray-1">
113 <li
114 v-for="t in section.tickets"
115 :key="t.name"
116 @click="openTicket(t.name)"
117 >
118 <div
119 class="-mx-2 cursor-pointer rounded-4 px-2 py-3 transition-colors hover:bg-surface-gray-2"
120 >
121 <p
122 class="font-base mb-2 truncate text-sm text-ink-gray-9 max-w-[70%]"
123 >
124 {{ t.subject }}
125 </p>
126 <div class="flex items-center justify-between gap-2">
127 <p class="shrink-0 text-sm text-ink-gray-5">
128 {{ formatDate(t.creation as string) + " · " }}
129 <span class="">{{ "#" + t.name }}</span>
130 </p>
131 <span
132 class="font-base shrink-0 rounded-1 px-2 py-0.5 text-xs"
133 :class="getStatusColor(t.status as string)"
134 >
135 {{ t.status }}
136 </span>
137 </div>
138 </div>
139 </li>
140 </ul>
141 </Section>
142 </div>
143 </template>
144 </div>
145 <!-- Absorbs the height a collapsing section removes, so scrollTop never
146 clamps and the clicked header stays put. Trimmed on scroll up. -->
147 <div :style="{ height: `${scrollSpacerHeight}px` }" />
148 </div>
149 </div>
150</template>
151
152<script setup lang="ts">
153import { parseField } from "@/composables/fieldDependsOn";
154import { useNotifyTicketUpdate } from "@/composables/realtime";
155import { useShortcut } from "@opendesk/shared/shortcuts";
156import { getMeta } from "@/stores/meta";
157import { useTicketStatusStore } from "@/stores/ticketStatus";
158import { __ } from "@/translation.ts";
159import {
160 AssigneeSymbol,
161 CustomizationSymbol,
162 FieldValue,
163 RecentSimilarTicketsSymbol,
164 TicketSymbol,
165} from "@/types";
166import { useResizeObserver, useStorage } from "@vueuse/core";
167import { dayjs } from "frappe-ui";
168import { computed, inject, ref } from "vue";
169import FieldLabel from "../FieldLabel.vue";
170import Section from "../Section.vue";
171import Tags from "../tag/Tags.vue";
172import TicketField from "../TicketField.vue";
173import AssignTo from "./AssignTo.vue";
174import TicketContact from "./TicketContact.vue";
175import TicketFeedback from "./TicketFeedback.vue";
176import TicketSLA from "./TicketSLA.vue";
177
178const ticket = inject(TicketSymbol)!;
179const assignees = inject(AssigneeSymbol)!;
180const customizations = inject(CustomizationSymbol)!;
181const recentSimilarTickets = inject(RecentSimilarTicketsSymbol)!;
182const { getFields, getField } = getMeta("OD Ticket");
183const { notifyTicketUpdate } = useNotifyTicketUpdate(ticket.value?.name);
184
185const dateFormat = window.date_format;
186const { getStatus, colorMap } = useTicketStatusStore();
187
188const CORE_FIELDS = ["priority", "ticket_type", "customer", "agent_group"];
189
190const coreFields = computed(() => {
191 const fieldsMeta = getFields();
192 if (!fieldsMeta || fieldsMeta.length === 0) {
193 return [];
194 }
195 const shown = new Set(
196 (customizations.value.data?.fields || []).map((f) => f.fieldname)
197 );
198 return CORE_FIELDS.filter((f) => shown.has(f))
199 .map((fieldname) => {
200 let field = getField(fieldname);
201 if (!field) return null;
202 field = parseField(field, ticket.value.doc);
203 // cant handle required depends on as we directly set the value in DB on change
204 field["required"] = field.reqd;
205 const formatted = getFieldInFormat(field, field);
206 formatted["visible"] = true;
207 return formatted;
208 })
209 .filter(Boolean);
210});
211
212const customFields = computed(() => {
213 const fieldsMeta = getFields();
214 if (!fieldsMeta || fieldsMeta.length === 0) {
215 return [];
216 }
217
218 if (!customizations.value.data || customizations.value.loading) return [];
219 let customFields = customizations.value.data?.fields || [];
220 const excludedFields = [...CORE_FIELDS, "subject", "status"];
221 customFields = customFields.filter(
222 (f) => !excludedFields.includes(f.fieldname)
223 );
224 let _customFields = customFields
225 .map((f) => {
226 let fieldMeta = getField(f.fieldname);
227 if (!fieldMeta) return null;
228
229 fieldMeta = parseField(fieldMeta, ticket.value.doc);
230 // cant handle required depends on as we directly set the value in DB
231 fieldMeta["required"] = fieldMeta.reqd || f.required;
232
233 return getFieldInFormat(f, fieldMeta);
234 })
235 .filter(Boolean);
236 return _customFields;
237});
238
239// When a section collapses, the scroller's content gets shorter and the
240// browser clamps scrollTop, yanking the viewport. The spacer absorbs the
241// lost height so nothing on screen moves; scrolling up trims it back, so
242// the blank space can never be scrolled into.
243const scrollAreaRef = ref<HTMLElement | null>(null);
244const scrollContentRef = ref<HTMLElement | null>(null);
245const scrollSpacerHeight = ref(0);
246
247useResizeObserver(scrollContentRef, () => {
248 scrollSpacerHeight.value = neededScrollSpacer();
249});
250
251function neededScrollSpacer(): number {
252 const area = scrollAreaRef.value;
253 const content = scrollContentRef.value;
254 if (!area || !content) return 0;
255 return Math.max(0, area.scrollTop + area.clientHeight - content.offsetHeight);
256}
257
258function trimScrollSpacer() {
259 scrollSpacerHeight.value = Math.min(
260 scrollSpacerHeight.value,
261 neededScrollSpacer()
262 );
263}
264
265const openedSections = useStorage(
266 "openedSections",
267 {
268 feedback: true,
269 keyInfo: true,
270 ticketInfo: true,
271 recentTickets: false,
272 similarTickets: false,
273 },
274 localStorage,
275 { mergeDefaults: true }
276);
277
278const sections = computed(() => {
279 if (recentSimilarTickets.value.loading || !recentSimilarTickets.value.data) {
280 return [];
281 }
282 const recentTickets = recentSimilarTickets.value?.data?.recent_tickets || [];
283 const similarTickets =
284 recentSimilarTickets.value?.data?.similar_tickets || [];
285 const _sections = [];
286 if (recentTickets.length) {
287 _sections.push({
288 key: "recentTickets" as const,
289 label: __("Recent Tickets"),
290 tooltipMessage: __("Tickets recently raised by this contact/customer"),
291 hideLabel: false,
292 tickets: recentTickets,
293 });
294 }
295 if (similarTickets.length) {
296 _sections.push({
297 key: "similarTickets" as const,
298 label: __("Similar Tickets"),
299 tooltipMessage: __("Tickets with similar queries"),
300 hideLabel: false,
301 tickets: similarTickets,
302 });
303 }
304 return _sections;
305});
306
307function getStatusColor(status: string) {
308 const { color } = getStatus(status) ?? {};
309 return colorMap[color] ?? colorMap["Default"];
310}
311
312function formatDate(date: string) {
313 return dayjs(date).format(dateFormat.toUpperCase());
314}
315
316function openTicket(name: string) {
317 let url = window.location.origin + "/opendesk/tickets/" + name;
318 window.open(url, "_blank");
319}
320
321function getFieldInFormat(fieldTemplate, fieldMeta) {
322 return {
323 label: fieldMeta?.label || fieldTemplate.fieldname,
324 value: ticket.value.doc[fieldTemplate.fieldname],
325 fieldtype: fieldMeta?.fieldtype,
326 doctype: fieldMeta?.options || "",
327 options: fieldMeta?.options || "",
328 placeholder:
329 fieldTemplate.placeholder ||
330 `Set ${fieldMeta?.label || fieldTemplate.fieldname}...`,
331 readonly: Boolean(fieldMeta.read_only),
332 disabled: Boolean(fieldMeta.read_only),
333 url_method: fieldTemplate.url_method || "",
334 fieldname: fieldTemplate.fieldname,
335 required: fieldTemplate.required || fieldMeta?.required || false,
336 visible:
337 fieldMeta.display_via_depends_on &&
338 !fieldMeta.hidden &&
339 (!!ticket.value.doc[fieldTemplate.fieldname] || !fieldMeta.read_only),
340 };
341}
342
343const normalize = (v: string | FieldValue) =>
344 v === null || v === undefined ? "" : v;
345
346function handleFieldUpdate(
347 fieldname: string,
348 value: FieldValue,
349 isCoreFieldUpdated = false
350) {
351 if (normalize(ticket.value.doc[fieldname]) == normalize(value)) return;
352 if (isCoreFieldUpdated) {
353 const label = getField(fieldname)?.label || fieldname;
354 notifyTicketUpdate(label, value as string);
355 }
356 ticket.value.setValue.submit(
357 { [fieldname]: value },
358 {
359 onSuccess: () => {
360 if (fieldname === "agent_group") {
361 assignees.value.reload();
362 }
363 },
364 }
365 );
366}
367
368const fieldRefs = ref<Record<string, any>>({});
369
370const setFieldRef = (fieldname: string, el: any) => {
371 if (el) {
372 fieldRefs.value[fieldname] = el;
373 }
374};
375
376const showRecentSimilarTickets = computed(() => {
377 return (
378 !recentSimilarTickets.value.loading &&
379 (recentSimilarTickets.value?.data?.recent_tickets?.length ||
380 recentSimilarTickets.value?.data?.similar_tickets?.length)
381 );
382});
383
384useShortcut("t", () => openFieldDropdown("ticket_type"));
385
386useShortcut("p", () => openFieldDropdown("priority"));
387
388useShortcut({ key: "t", shift: true }, () => openFieldDropdown("agent_group"));
389
390// The @framework/ui Link renders a [role=combobox] input, not a button, and
391// opens on ArrowDown from a focused input (a synthetic click won't open it).
392function openFieldDropdown(fieldname: string) {
393 const input = fieldRefs.value?.[fieldname]?.$el?.querySelector(
394 '[role="combobox"]'
395 ) as HTMLElement | null;
396 if (!input) return;
397 input.focus();
398 input.dispatchEvent(
399 new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
400 );
401}
402</script>
403