1<template>
2 <SettingsLayoutBase :description="__('Manage general settings of your app.')">
3 <template #title>
4 <div class="flex items-center gap-2">
5 <h1 class="text-md-semibold text-ink-gray-8">
6 {{ __("General") }}
7 </h1>
8 <UnsavedBadge :show="isDirty" />
9 </div>
10 </template>
11 <template #header-actions>
12 <Transition name="fade">
13 <div v-if="isDirty">
14 <Button
15 :label="__('Save')"
16 variant="solid"
17 @click="saveSettings"
18 :loading="saveSettingsResource.loading"
19 />
20 </div>
21 </Transition>
22 </template>
23 <template #content>
24 <div
25 v-if="settingsDataResource.loading && !settingsDataResource.data"
26 class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
27 >
28 <LoadingIndicator class="w-4" />
29 </div>
30 <div v-else>
31 <Branding />
32 <hr class="my-8" />
33 <TicketSettings />
34 <hr class="my-8" />
35 <WorkflowSettings />
36 </div>
37 </template>
38 </SettingsLayoutBase>
39</template>
40
41<script setup lang="ts">
42import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
43import UnsavedBadge from "@/components/UnsavedBadge.vue";
44import { useConfigStore } from "@/stores/config";
45import { __ } from "@/translation";
46import { ODSettings, ODSettingsSymbol } from "@/types";
47import { Button, createResource, LoadingIndicator, toast } from "frappe-ui";
48import { provide, ref, watch } from "vue";
49import { disableSettingModalOutsideClick } from "../settingsModal";
50import Branding from "./components/Branding.vue";
51import TicketSettings from "./components/TicketSettings.vue";
52import WorkflowSettings from "./components/WorkflowSettings.vue";
53
54const configStore = useConfigStore();
55
56const isDirty = ref(false);
57const initialData = ref<null | string>(null);
58const settingsData = ref({
59 brandName: "",
60 brandLogo: "",
61 favicon: "",
62 autoCloseAfterDays: "",
63 autoCloseStatus: "",
64 autoCloseTickets: "",
65 assignWithinTeam: false,
66 doNotRestrictTicketsWithoutAnAgentGroup: false,
67 restrictTicketsByAgentGroup: false,
68 updateStatusTo: "",
69 autoUpdateStatus: false,
70 isFeedbackMandatory: false,
71 enableCommentReactions: false,
72 allowAnyoneToCreateTickets: false,
73 defaultTicketType: "",
74 skipEmailWorkflow: false,
75 disableSavedRepliesGlobalScope: false,
76 enableOutsideHoursBanner: false,
77 outsideWorkingHoursBannerMessage: "",
78});
79
80provide(ODSettingsSymbol, settingsData);
81
82const settingsDataResource = createResource({
83 url: "frappe.client.get",
84 params: {
85 doctype: "OD Settings",
86 name: "OD Settings",
87 },
88 auto: true,
89 onSuccess(data: ODSettings) {
90 settingsData.value = transformData(data);
91 initialData.value = JSON.stringify(settingsData.value);
92 },
93});
94
95const saveSettingsResource = createResource({
96 url: "frappe.client.set_value",
97 makeParams() {
98 return {
99 doctype: "OD Settings",
100 name: "OD Settings",
101 fieldname: {
102 brand_name: settingsData.value.brandName,
103 auto_close_after_days: Number(settingsData.value.autoCloseAfterDays),
104 auto_close_status: settingsData.value.autoCloseStatus,
105 auto_close_tickets: settingsData.value.autoCloseTickets,
106 assign_within_team: settingsData.value.assignWithinTeam,
107 do_not_restrict_tickets_without_an_agent_group:
108 settingsData.value.doNotRestrictTicketsWithoutAnAgentGroup,
109 restrict_tickets_by_agent_group:
110 settingsData.value.restrictTicketsByAgentGroup,
111 update_status_to: settingsData.value.updateStatusTo,
112 auto_update_status: settingsData.value.autoUpdateStatus,
113 is_feedback_mandatory: settingsData.value.isFeedbackMandatory,
114 enable_comment_reactions: settingsData.value.enableCommentReactions,
115 allow_anyone_to_create_tickets:
116 settingsData.value.allowAnyoneToCreateTickets,
117 default_ticket_type: settingsData.value.defaultTicketType,
118 skip_email_workflow: settingsData.value.skipEmailWorkflow,
119 disable_saved_replies_global_scope:
120 settingsData.value.disableSavedRepliesGlobalScope,
121
122 enable_outside_hours_banner:
123 settingsData.value.enableOutsideHoursBanner,
124 outside_working_hours_message:
125 settingsData.value.outsideWorkingHoursBannerMessage,
126 },
127 };
128 },
129 onSuccess(data: ODSettings) {
130 settingsData.value = transformData(data);
131 initialData.value = JSON.stringify(settingsData.value);
132 configStore.configResource.reload();
133 },
134});
135
136const transformData = (data: any) => {
137 return {
138 brandName: data.brand_name,
139 brandLogo: data.brand_logo,
140 favicon: data.favicon,
141 autoCloseAfterDays: data.auto_close_after_days,
142 autoCloseStatus: data.auto_close_status,
143 autoCloseTickets: data.auto_close_tickets,
144 assignWithinTeam: Boolean(data.assign_within_team),
145 doNotRestrictTicketsWithoutAnAgentGroup: Boolean(
146 data.do_not_restrict_tickets_without_an_agent_group
147 ),
148 restrictTicketsByAgentGroup: Boolean(data.restrict_tickets_by_agent_group),
149 updateStatusTo: data.update_status_to,
150 autoUpdateStatus: data.auto_update_status,
151 isFeedbackMandatory: Boolean(data.is_feedback_mandatory),
152 enableCommentReactions: Boolean(data.enable_comment_reactions),
153 allowAnyoneToCreateTickets: Boolean(data.allow_anyone_to_create_tickets),
154 defaultTicketType: data.default_ticket_type,
155 skipEmailWorkflow: Boolean(data.skip_email_workflow),
156 disableSavedRepliesGlobalScope: Boolean(
157 data.disable_saved_replies_global_scope
158 ),
159 enableOutsideHoursBanner: Boolean(data.enable_outside_hours_banner),
160 outsideWorkingHoursBannerMessage: data.outside_working_hours_message || "",
161 };
162};
163
164const saveSettings = async () => {
165 if (
166 settingsData.value.restrictTicketsByAgentGroup &&
167 !settingsData.value.doNotRestrictTicketsWithoutAnAgentGroup &&
168 !settingsData.value.assignWithinTeam
169 ) {
170 toast.error(
171 __(
172 "Please select at least one restriction option for teams in the settings."
173 )
174 );
175 return;
176 }
177 const promises = [];
178 if (isDirty.value) {
179 promises.push(saveSettingsResource.submit());
180 }
181 await Promise.allSettled(promises).then(() => {
182 toast.success(__("Settings updated"));
183 });
184};
185
186const toggleFieldnames = {
187 isFeedbackMandatory: "is_feedback_mandatory",
188 enableCommentReactions: "enable_comment_reactions",
189 disableSavedRepliesGlobalScope: "disable_saved_replies_global_scope",
190 allowAnyoneToCreateTickets: "allow_anyone_to_create_tickets",
191 skipEmailWorkflow: "skip_email_workflow",
192} as const;
193const toggleFields = Object.keys(toggleFieldnames) as Array<
194 keyof typeof toggleFieldnames
195>;
196
197// Toggles save on their own, so they never carry unsaved text fields along.
198const saveTogglesResource = createResource({
199 url: "frappe.client.set_value",
200 makeParams: (values: Record<string, unknown>) => ({
201 doctype: "OD Settings",
202 name: "OD Settings",
203 fieldname: Object.fromEntries(
204 toggleFields.map((f) => [toggleFieldnames[f], values[f]])
205 ),
206 }),
207});
208let pendingToggleSave = Promise.resolve();
209
210function currentToggles() {
211 return Object.fromEntries(
212 toggleFields.map((f) => [f, settingsData.value[f]])
213 );
214}
215
216async function saveToggles(sent: Record<string, unknown>) {
217 try {
218 await saveTogglesResource.submit(sent);
219 markTogglesSaved(sent);
220 toast.success(__("Settings updated"));
221 } catch {
222 revertToggles(sent);
223 }
224}
225
226function markTogglesSaved(sent: Record<string, unknown>) {
227 const initial = JSON.parse(initialData.value!);
228 toggleFields.forEach((f) => (initial[f] = sent[f]));
229 initialData.value = JSON.stringify(initial);
230 configStore.configResource.reload();
231}
232
233// Undo only what the failed request sent; a toggle changed since keeps its queued save.
234function revertToggles(sent: Record<string, unknown>) {
235 const initial = JSON.parse(initialData.value!);
236 toggleFields
237 .filter((f) => settingsData.value[f] === sent[f])
238 .forEach((f) => (settingsData.value[f] = initial[f]));
239}
240
241// Track dirty state for non-toggle fields only
242watch(
243 settingsData,
244 (data) => {
245 if (!initialData.value) return;
246 const initial = JSON.parse(initialData.value);
247 isDirty.value = Object.keys(data).some(
248 (key) =>
249 !(toggleFields as string[]).includes(key) &&
250 JSON.stringify(data[key as keyof typeof data]) !==
251 JSON.stringify(initial[key])
252 );
253 disableSettingModalOutsideClick.value = isDirty.value;
254 },
255 { deep: true }
256);
257
258// Queue toggle saves: parallel writes to the single doc fail with a 417.
259watch(
260 () => toggleFields.map((f) => settingsData.value[f]),
261 (newVals) => {
262 if (!initialData.value) return;
263 const initial = JSON.parse(initialData.value);
264 if (!newVals.some((v, i) => v !== initial[toggleFields[i]])) return;
265 pendingToggleSave = pendingToggleSave.then(() =>
266 saveToggles(currentToggles())
267 );
268 }
269);
270</script>
271