1<template>
2 <SettingsLayoutBase
3 :title="__('Saved Replies')"
4 :description="
5 __(
6 'Manage pre-defined responses that can be used to respond to customer queries.'
7 )
8 "
9 >
10 <template #header-actions>
11 <Button
12 :label="__('New')"
13 theme="gray"
14 variant="solid"
15 @click="goToNew()"
16 icon-left="lucide-plus"
17 class="rtl:flex-row-reverse"
18 />
19 </template>
20 <template #header-bottom>
21 <div class="flex items-center gap-2 justify-between">
22 <div class="relative w-full">
23 <TextInput
24 :model-value="savedRepliesSearchQuery"
25 @update:model-value="savedRepliesSearchQuery = $event"
26 :placeholder="__('Search')"
27 type="text"
28 :debounce="300"
29 >
30 <template #prefix>
31 <LucideSearch class="size-4" />
32 </template>
33 </TextInput>
34 <Button
35 v-if="savedRepliesSearchQuery"
36 icon="lucide-x"
37 variant="ghost"
38 @click="savedRepliesSearchQuery = ''"
39 class="absolute end-1 top-1/2 -translate-y-1/2"
40 />
41 </div>
42 <Dropdown
43 :options="filterOptions"
44 align="end"
45 @update:open="(open) => open && (isConfirmingDelete = false)"
46 >
47 <template #default="{ open }">
48 <Button :label="activeFilterLabel">
49 <template #suffix>
50 <component
51 :is="open ? LucideChevronUp : LucideChevronDown"
52 class="h-4"
53 />
54 </template>
55 </Button>
56 </template>
57 </Dropdown>
58 </div>
59 </template>
60 <template #content>
61 <div
62 v-if="savedRepliesListResource?.list?.loading"
63 class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
64 >
65 <LoadingIndicator class="w-4" />
66 </div>
67 <EmptyState
68 v-if="
69 !savedRepliesListResource?.list?.loading &&
70 !savedRepliesListResource?.data?.length
71 "
72 variant="badge"
73 :icon="ZapIcon"
74 :title="__('No saved replies found')"
75 :description="__('Add one to get started.')"
76 />
77 <div
78 v-if="
79 !savedRepliesListResource?.list?.loading &&
80 savedRepliesListResource?.data?.length
81 "
82 class="-ms-2"
83 >
84 <div
85 class="grid grid-cols-12 items-center gap-3 text-sm text-ink-gray-5 ms-2"
86 >
87 <div class="col-span-7">{{ __("Title") }}</div>
88 <div class="col-span-2">{{ __("Owner") }}</div>
89 <div class="col-span-3">{{ __("Scope") }}</div>
90 </div>
91 <hr class="mt-2 mx-2" />
92 <div
93 v-for="(savedReply, index) in savedRepliesListResource?.data"
94 :key="savedReply.name"
95 >
96 <div
97 class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
98 >
99 <div
100 @click="
101 savedRepliesActiveScreen = {
102 screen: 'view',
103 data: savedReply,
104 }
105 "
106 class="w-full px-2 flex flex-col justify-center h-12.5 col-span-7 min-w-0"
107 >
108 <div class="text-base-medium text-ink-gray-7 w-full truncate">
109 {{ savedReply.title }}
110 </div>
111 </div>
112 <div
113 class="flex items-center gap-1.5 text-sm text-ink-gray-7 col-span-2 min-w-0"
114 >
115 <Avatar
116 :label="
117 getUser(savedReply.owner)?.full_name || savedReply.owner
118 "
119 :image="getUser(savedReply.owner)?.user_image"
120 size="xs"
121 class="shrink-0"
122 />
123 <span class="truncate">{{
124 getUser(savedReply.owner)?.full_name
125 }}</span>
126 </div>
127 <div
128 class="flex justify-between items-center w-full pe-2 col-span-3"
129 >
130 <div class="flex items-center gap-1 text-sm text-ink-gray-7">
131 <component
132 :is="getScopeIcon(savedReply.scope)"
133 class="size-4"
134 />
135 {{ savedReply.scope }}
136 </div>
137 <!-- the confirm state is shared, so each menu clears it on open -->
138 <Dropdown
139 align="end"
140 :options="dropdownOptions(savedReply)"
141 @update:open="(open) => open && (isConfirmingDelete = false)"
142 >
143 <Button
144 icon="lucide-more-horizontal"
145 variant="ghost"
146 class="me-2"
147 />
148 </Dropdown>
149 </div>
150 </div>
151 <hr
152 v-if="index !== savedRepliesListResource.data.length - 1"
153 class="mx-2"
154 />
155 </div>
156 </div>
157 </template>
158 </SettingsLayoutBase>
159 <Dialog
160 :title="__('Duplicate Saved reply')"
161 v-model:open="duplicateDialog.show"
162 >
163 <template #default>
164 <div class="flex flex-col gap-4">
165 <FormControl
166 :label="__('New Saved reply Name')"
167 type="text"
168 v-model="duplicateDialog.newTitle"
169 />
170 </div>
171 </template>
172 <template #actions>
173 <div class="flex gap-2 justify-end">
174 <Button
175 variant="subtle"
176 :label="__('Close')"
177 @click="duplicateDialog.show = false"
178 />
179 <Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
180 </div>
181 </template>
182 </Dialog>
183</template>
184
185<script setup lang="ts">
186import LucideChevronUp from "~icons/lucide/chevron-up";
187import LucideChevronDown from "~icons/lucide/chevron-down";
188import EmptyState from "@/components/EmptyState.vue";
189import { useConfigStore } from "@/stores/config";
190import { __ } from "@/translation";
191import { ConfirmDelete } from "@/utils";
192import {
193 Avatar,
194 Button,
195 call,
196 Dropdown,
197 LoadingIndicator,
198 TextInput,
199 toast,
200} from "frappe-ui";
201import { storeToRefs } from "pinia";
202import { computed, inject, ref, Ref, watch } from "vue";
203import GlobeIcon from "~icons/lucide/globe";
204import UserIcon from "~icons/lucide/user";
205import UsersIcon from "~icons/lucide/users";
206import { useUserStore } from "../../../stores/user";
207import { SavedReply, SavedReplyListResourceSymbol } from "../../../types";
208import ZapIcon from "~icons/lucide/zap";
209import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
210import { activeFilter } from "./savedReplies";
211
212const { getUser } = useUserStore();
213const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
214 storeToRefs(useConfigStore());
215
216const savedRepliesSearchQuery = inject<Ref<string>>("savedRepliesSearchQuery");
217const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
218const duplicateDialog = ref({
219 show: false,
220 name: "",
221 title: "",
222 newTitle: "",
223});
224const savedRepliesList = ref([]);
225
226const goToNew = () => {
227 savedRepliesActiveScreen.value = {
228 screen: "view",
229 data: {
230 scope: activeFilter.value
231 ? activeFilter.value === "All"
232 ? "Personal"
233 : activeFilter.value
234 : "Personal",
235 },
236 };
237};
238
239const isConfirmingDelete = ref(false);
240
241const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
242
243const dropdownOptions = (savedReply: SavedReply) => [
244 {
245 label: __("Duplicate"),
246 onClick: () => {
247 duplicateDialog.value = {
248 show: true,
249 name: savedReply.name,
250 title: savedReply.title,
251 newTitle: `${savedReply.title} (Copy)`,
252 };
253 },
254 icon: "lucide-copy",
255 },
256 ...ConfirmDelete({
257 onConfirmDelete: () => deleteSavedReply(savedReply),
258 isConfirmingDelete,
259 }),
260];
261
262const deleteSavedReply = (savedReply: SavedReply) => {
263 if (!isConfirmingDelete.value) {
264 isConfirmingDelete.value = true;
265 return;
266 }
267
268 savedRepliesListResource?.delete.submit(savedReply.name, {
269 onSuccess: () => {
270 toast.success(__("Saved reply deleted successfully."));
271 },
272 });
273};
274
275const duplicate = async () => {
276 await call("frappe.client.get", {
277 doctype: "OD Saved Reply",
278 name: duplicateDialog.value.name,
279 }).then((data: SavedReply) => {
280 savedRepliesListResource?.insert.submit(
281 {
282 ...data,
283 title: duplicateDialog.value.newTitle,
284 },
285 {
286 onSuccess: (data) => {
287 toast.success(__("Saved reply duplicated successfully."));
288 duplicateDialog.value = {
289 show: false,
290 name: "",
291 title: "",
292 newTitle: "",
293 };
294 savedRepliesActiveScreen.value = {
295 screen: "view",
296 data: { name: data.name },
297 };
298 },
299 }
300 );
301 });
302};
303
304const filterOptions = computed(() => {
305 const scopes = [
306 { label: __("All"), value: "All" },
307 { label: __("Personal"), value: "Personal" },
308 { label: __("My Team(s)"), value: "Team" },
309 { label: __("Global"), value: "Global" },
310 ];
311 if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
312 scopes.pop();
313 }
314 return scopes.map((scope) => ({
315 ...scope,
316 selected: activeFilter.value === scope.value,
317 onClick: () => applyFilter(scope.value),
318 }));
319});
320
321const activeFilterLabel = computed(() => {
322 return (
323 filterOptions.value.find((option) => option.value === activeFilter.value)
324 ?.label ?? activeFilter.value
325 );
326});
327
328const applyFilter = (scope: string) => {
329 if (!savedRepliesListResource) return;
330 activeFilter.value = scope;
331 savedRepliesListResource.filters = {
332 ...savedRepliesListResource?.filters,
333 scope: scope == "All" ? undefined : ["=", scope],
334 };
335 savedRepliesListResource.list.reload();
336};
337
338const getScopeIcon = (scope: string) => {
339 const icons = [
340 {
341 label: __("Personal"),
342 icon: UserIcon,
343 },
344 {
345 label: __("Team"),
346 icon: UsersIcon,
347 },
348 {
349 label: __("Global"),
350 icon: GlobeIcon,
351 },
352 ];
353 return icons.find((x) => x.label === scope)?.icon;
354};
355
356watch(
357 () => savedRepliesSearchQuery?.value,
358 (newValue) => {
359 if (!savedRepliesListResource) return;
360
361 savedRepliesListResource.filters = {
362 ...savedRepliesListResource.filters,
363 title: ["like", `%${newValue}%`],
364 };
365 savedRepliesListResource.list.reload();
366 }
367);
368</script>
369