1import { useNotifyTicketUpdate } from "@/composables/realtime";
2import { reloadTicketFeed, useTicket } from "@/composables/useTicket";
3import {
4 canNavigateTickets,
5 getNextTicket,
6 getPreviousTicket,
7 goToNextTicket,
8 goToPreviousTicket,
9} from "@/composables/useTicketNavigation";
10import {
11 showMergeModal,
12 toggleCommentBox,
13 toggleEmailBox,
14} from "@/pages/ticket/modalStates";
15import { useAgentStore } from "@/stores/agent";
16import { getMeta } from "@/stores/meta";
17import { capture } from "@/telemetry";
18import { __ } from "@/translation";
19import { copyToClipboard } from "@opendesk/shared/utils";
20import { createListResource, createResource, toast } from "frappe-ui";
21import { priorityOptions, statusOptions } from "./optionCommands";
22import {
23 CONTEXT_WEIGHT,
24 FLAT_OPTION_WEIGHT,
25 GROUP,
26 type Command,
27} from "./paletteTypes";
28import { savedReplyCommands } from "./savedReplyCommands";
29import { tagChildren } from "./tagCommands";
30
31// Same icons as the composer buttons these rows press.
32import { CommentIcon, EmailIcon } from "@/components/icons/";
33import LucideArrowLeft from "~icons/lucide/arrow-left";
34import LucideArrowRight from "~icons/lucide/arrow-right";
35import LucideCircleDot from "~icons/lucide/circle-dot";
36import LucideCopy from "~icons/lucide/copy";
37import LucideFlag from "~icons/lucide/flag";
38import LucideLink from "~icons/lucide/link";
39import LucideMerge from "~icons/lucide/merge";
40import LucideShapes from "~icons/lucide/shapes";
41import LucideTags from "~icons/lucide/tags";
42import LucideUserPlus from "~icons/lucide/user-plus";
43import LucideUsers from "~icons/lucide/users";
44
45// --- ticket context ------------------------------------------------------
46
47/** Actions on the open ticket. `weight` keeps them above fixed-rank search hits. */
48export function ticketCommands(ticketId: string): Command[] {
49 const commands: Command[] = [
50 {
51 id: "ticket-status",
52 title: __("Change status"),
53 group: GROUP.ticket,
54 weight: CONTEXT_WEIGHT,
55 icon: LucideCircleDot,
56 hint: "S",
57 // No option names: the flat "Set status: Open" rows own those terms.
58 keywords: "state",
59 children: () => statusChildren(ticketId),
60 },
61 {
62 id: "ticket-priority",
63 title: __("Change priority"),
64 group: GROUP.ticket,
65 weight: CONTEXT_WEIGHT,
66 icon: LucideFlag,
67 hint: "P",
68 keywords: "level",
69 children: () => priorityChildren(ticketId),
70 },
71 {
72 id: "ticket-assign",
73 title: __("Assign to"),
74 group: GROUP.ticket,
75 weight: CONTEXT_WEIGHT,
76 icon: LucideUserPlus,
77 hint: "A",
78 keywords: "agent owner assignee",
79 children: () => agentChildren(ticketId),
80 },
81 ...assignToMeCommand(ticketId),
82 {
83 id: "ticket-team",
84 title: __("Change team"),
85 group: GROUP.ticket,
86 weight: CONTEXT_WEIGHT,
87 icon: LucideUsers,
88 hint: "Shift+T",
89 keywords: "group agent_group escalation escalate",
90 children: () => teamChildren(ticketId),
91 },
92 {
93 id: "ticket-type",
94 title: __("Change type"),
95 group: GROUP.ticket,
96 weight: CONTEXT_WEIGHT,
97 icon: LucideShapes,
98 hint: "T",
99 children: () => ticketTypeChildren(ticketId),
100 },
101 {
102 id: "ticket-tags",
103 title: __("Tags"),
104 group: GROUP.ticket,
105 weight: CONTEXT_WEIGHT,
106 icon: LucideTags,
107 hint: "G",
108 keywords: "add remove label categorise",
109 children: () => tagChildren(ticketId),
110 },
111 {
112 id: "ticket-comment",
113 title: __("Add comment"),
114 group: GROUP.ticket,
115 weight: CONTEXT_WEIGHT,
116 icon: CommentIcon,
117 hint: "C",
118 keywords: "note internal",
119 perform: () => toggleCommentBox(),
120 },
121 {
122 id: "ticket-reply",
123 title: __("Reply to ticket"),
124 group: GROUP.ticket,
125 weight: CONTEXT_WEIGHT,
126 icon: EmailIcon,
127 hint: "R",
128 keywords: "email respond",
129 perform: () => toggleEmailBox(),
130 },
131 ...savedReplyCommands(ticketId),
132 ...flatOptionCommands(ticketId),
133 ...mergeCommand(ticketId),
134 ...navigationCommands(),
135 {
136 id: "ticket-copy-id",
137 title: __("Copy ticket ID"),
138 group: GROUP.ticket,
139 weight: CONTEXT_WEIGHT,
140 icon: LucideCopy,
141 hint: "Mod+.",
142 perform: () =>
143 copyToClipboard(ticketId, __("Ticket #{0} copied", [ticketId])),
144 },
145 {
146 id: "ticket-copy-url",
147 title: __("Copy ticket link"),
148 group: GROUP.ticket,
149 weight: CONTEXT_WEIGHT,
150 icon: LucideLink,
151 hint: "Mod+Shift+.",
152 perform: () =>
153 copyToClipboard(window.location.href, __("Ticket URL copied")),
154 },
155 ];
156
157 return commands;
158}
159
160/** Skips the agent drill-down; hidden once held — "Assign to" is the way back out. */
161function assignToMeCommand(ticketId: string): Command[] {
162 const me = window.agent;
163 if (!me || assignedNames(ticketId).includes(me)) return [];
164 return [
165 {
166 id: "ticket-assign-me",
167 title: __("Assign to me"),
168 group: GROUP.ticket,
169 weight: CONTEXT_WEIGHT,
170 icon: LucideUserPlus,
171 keywords: "claim mine myself take",
172 perform: () => assignTicket(ticketId, me),
173 },
174 ];
175}
176
177/** Same guards as the ticket header's own Merge action. */
178function mergeCommand(ticketId: string): Command[] {
179 const doc = useTicket(ticketId).ticket.doc;
180 const mergeable =
181 doc &&
182 !doc.is_merged &&
183 ["Open", "Paused"].includes(doc.status_category ?? "");
184 if (!mergeable) return [];
185 return [
186 {
187 id: "ticket-merge",
188 title: __("Merge ticket"),
189 group: GROUP.ticket,
190 weight: CONTEXT_WEIGHT,
191 icon: LucideMerge,
192 keywords: "combine duplicate join",
193 perform: () => (showMergeModal.value = true),
194 },
195 ];
196}
197
198/** Only offered when there's somewhere to go, so the rows never no-op. */
199function navigationCommands(): Command[] {
200 if (!canNavigateTickets()) return [];
201 const commands: Command[] = [];
202 const next = getNextTicket();
203 const previous = getPreviousTicket();
204 if (next) {
205 commands.push({
206 id: "ticket-next",
207 title: __("Next ticket"),
208 subtitle: `#${next}`,
209 group: GROUP.ticket,
210 weight: CONTEXT_WEIGHT,
211 icon: LucideArrowRight,
212 hint: "Shift+>",
213 perform: () => goToNextTicket(),
214 });
215 }
216 if (previous) {
217 commands.push({
218 id: "ticket-previous",
219 title: __("Previous ticket"),
220 subtitle: `#${previous}`,
221 group: GROUP.ticket,
222 weight: CONTEXT_WEIGHT,
223 icon: LucideArrowLeft,
224 hint: "Shift+<",
225 perform: () => goToPreviousTicket(),
226 });
227 }
228 return commands;
229}
230
231/** Every option as a root row, so "urgent" sets the priority without a picker;
232 * hidden until typed — the drill-downs stay the discoverable path. */
233function flatOptionCommands(ticketId: string): Command[] {
234 const { doc } = useTicket(ticketId).ticket;
235 const flat = { hideWhenEmpty: true, weight: FLAT_OPTION_WEIGHT };
236 return [
237 ...statusOptions({
238 ...flat,
239 group: GROUP.ticket,
240 titlePrefix: "Set status",
241 current: doc?.status,
242 onPick: (status) => updateTicket(ticketId, { status }),
243 }),
244 ...priorityOptions({
245 ...flat,
246 group: GROUP.ticket,
247 titlePrefix: "Set priority",
248 current: doc?.priority,
249 onPick: (priority) => updateTicket(ticketId, { priority }),
250 }),
251 ];
252}
253
254function statusChildren(ticketId: string): Command[] {
255 return statusOptions({
256 group: "Set status",
257 current: useTicket(ticketId).ticket.doc?.status,
258 onPick: (status) => updateTicket(ticketId, { status }),
259 });
260}
261
262function priorityChildren(ticketId: string): Command[] {
263 return priorityOptions({
264 group: "Set priority",
265 current: useTicket(ticketId).ticket.doc?.priority,
266 onPick: (priority) => updateTicket(ticketId, { priority }),
267 });
268}
269
270async function agentChildren(ticketId: string): Promise<Command[]> {
271 const store = useAgentStore();
272 if (!store.agents.data) await store.agents.reload();
273 const assigned = assignedNames(ticketId);
274 return (store.agents.data ?? []).map((agent) => ({
275 id: `agent-${agent.name}`,
276 title: agent.agent_name || agent.name,
277 // agent_name is often just the email; don't print it twice.
278 subtitle: agent.agent_name === agent.name ? "" : agent.name,
279 group: "Assign to",
280 avatar: {
281 image: agent.user_image,
282 label: agent.agent_name || agent.name,
283 },
284 // Rows add, never replace — the tick is how you tell which it will do.
285 checked: assigned.includes(agent.name),
286 perform: () => assignTicket(ticketId, agent.name),
287 }));
288}
289
290function assignedNames(ticketId: string): string[] {
291 return (useTicket(ticketId).assignees.data ?? []).map(
292 (assignee) => assignee.name
293 );
294}
295
296const teams = createListResource({
297 doctype: "OD Team",
298 fields: ["name"],
299 pageLength: 100,
300});
301
302async function teamChildren(ticketId: string): Promise<Command[]> {
303 if (!teams.data) await teams.reload();
304 const current = useTicket(ticketId).ticket.doc?.agent_group;
305 return (teams.data ?? []).map((team) => ({
306 id: `team-${team.name}`,
307 title: team.name,
308 group: "Set team",
309 checked: team.name === current,
310 perform: () => updateTicket(ticketId, { agent_group: team.name }),
311 }));
312}
313
314const ticketTypes = createListResource({
315 doctype: "OD Ticket Type",
316 fields: ["name"],
317 pageLength: 100,
318});
319
320async function ticketTypeChildren(ticketId: string): Promise<Command[]> {
321 if (!ticketTypes.data) await ticketTypes.reload();
322 const current = useTicket(ticketId).ticket.doc?.ticket_type;
323 return (ticketTypes.data ?? []).map((type) => ({
324 id: `type-${type.name}`,
325 title: type.name,
326 group: "Set type",
327 checked: type.name === current,
328 perform: () => updateTicket(ticketId, { ticket_type: type.name }),
329 }));
330}
331
332// Module scope is safe: getMeta is cached per doctype and shares the fetch.
333const ticketMeta = getMeta("OD Ticket");
334
335/** From meta, so a site that relabels Team gets its own word in the broadcast. */
336function fieldLabel(fieldname: string): string {
337 return ticketMeta.getField(fieldname)?.label ?? fieldname;
338}
339
340function updateTicket(ticketId: string, changes: Record<string, string>): void {
341 const [field, value] = Object.entries(changes)[0] ?? [];
342 if (!field || value === undefined) return;
343 const { ticket } = useTicket(ticketId);
344 const current = ticket.doc as unknown as Record<string, unknown> | undefined;
345 // The drill-down already ticks the current value, so picking it again just
346 // closes quietly.
347 if (current?.[field] === value) return;
348 // Same order as the details tab: tell co-viewers, then write.
349 useNotifyTicketUpdate(ticketId).notifyTicketUpdate(fieldLabel(field), value);
350 ticket.setValue.submit(changes, {
351 onSuccess: () => reloadTicketFeed(ticketId),
352 });
353}
354
355const addAssignee = createResource({ url: "frappe.desk.form.assign_to.add" });
356
357/** Adds without dropping existing assignees; unassigning stays with the AssignTo
358 * widget. */
359async function assignTicket(ticketId: string, agent: string): Promise<void> {
360 const { assignees } = useTicket(ticketId);
361 if (assignedNames(ticketId).includes(agent)) {
362 toast.success(__("Already assigned to {0}", agent));
363 return;
364 }
365 try {
366 await addAssignee.submit({
367 doctype: "OD Ticket",
368 name: ticketId,
369 assign_to: [agent],
370 });
371 capture("ticket_assigned", {
372 data: { doctype: "OD Ticket", source: "command_palette" },
373 });
374 toast.success(__("Assignees updated successfully."));
375 assignees.reload();
376 reloadTicketFeed(ticketId);
377 } catch {
378 toast.error(__("Failed to update Assignees."));
379 }
380}
381