1<template>
2 <SettingsLayoutBase
3 :back-label="slaData.service_level || __('New SLA Policy')"
4 :on-back="goBack"
5 :dirty="isDirty"
6 >
7 <template #header-actions>
8 <div class="flex gap-4 items-center">
9 <div class="flex items-center justify-between gap-2">
10 <Switch
11 size="sm"
12 :model-value="Boolean(slaData.enabled)"
13 @update:model-value="(value) => (slaData.enabled = value)"
14 />
15 <!-- on the label only: on the wrapper it would undo the switch's
16 own toggle -->
17 <span
18 class="cursor-pointer text-sm-medium text-ink-gray-7"
19 @click="toggleEnabled"
20 >
21 {{ __("Enabled") }}
22 </span>
23 </div>
24 <Button
25 :label="__('Save')"
26 theme="gray"
27 variant="solid"
28 @click="saveSla()"
29 :disabled="Boolean(!isDirty && slaActiveScreen.data)"
30 :loading="slaData.loading || slaPolicyList.setValue.loading"
31 />
32 </div>
33 </template>
34 <template #content>
35 <div
36 v-if="slaData.loading"
37 class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
38 >
39 <LoadingIndicator class="w-4" />
40 </div>
41 <div v-if="!slaData.loading">
42 <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
43 <div class="space-y-5">
44 <div>
45 <FormControl
46 :type="'text'"
47 size="sm"
48 variant="subtle"
49 :placeholder="__('Name')"
50 :label="__('Name')"
51 v-model="slaData.service_level"
52 required
53 @change="validateSlaData('service_level')"
54 :disabled="Boolean(slaActiveScreen.data)"
55 maxlength="50"
56 />
57 <ErrorMessage
58 :message="slaDataErrors.service_level"
59 class="mt-2"
60 />
61 </div>
62 <div class="space-y-1.5" v-if="!slaData.default_sla">
63 <FormLabel :label="__('Rank')" for="rank" />
64 <FormControl
65 id="rank"
66 type="number"
67 min="0"
68 variant="subtle"
69 v-model="slaData.rank"
70 :description="
71 __(
72 'When more than one policy matches, the lower rank is applied first. 0 means unranked, and is applied last.'
73 )
74 "
75 />
76 </div>
77 </div>
78 <FormControl
79 :type="'textarea'"
80 size="sm"
81 variant="subtle"
82 :rows="8"
83 :placeholder="__('Description')"
84 :label="__('Description')"
85 v-model="slaData.description"
86 maxlength="140"
87 />
88 </div>
89 <hr class="my-8" />
90 <div>
91 <div class="flex flex-col gap-1">
92 <span class="text-md-semibold text-ink-gray-8">{{
93 __("Assignment Conditions")
94 }}</span>
95 <span class="text-p-sm text-ink-gray-6">
96 {{ __("Choose which tickets are affected by this policy.") }}
97 </span>
98 </div>
99 <div class="mt-3">
100 <div class="flex items-center justify-between">
101 <Checkbox
102 :label="__('Set as default SLA')"
103 :model-value="slaData.default_sla"
104 @update:model-value="toggleDefaultSla"
105 class="text-ink-gray-6 text-base-medium"
106 />
107 <div
108 v-if="isOldSla && slaActiveScreen.data && !slaData.default_sla"
109 >
110 <HoverCard :hoverDelay="250" side="top" align="end">
111 <template #trigger>
112 <div
113 class="text-sm text-ink-gray-6 flex gap-1 cursor-default"
114 >
115 {{ __("Old Conditions") }}
116 <LucideInfo class="size-4" />
117 </div>
118 </template>
119 <template #default>
120 <div
121 class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
122 >
123 <code>{{ slaData.condition }}</code>
124 </div>
125 </template>
126 </HoverCard>
127 </div>
128 </div>
129 <div class="mt-5" v-if="!slaData.default_sla">
130 <div
131 class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
132 v-if="!useNewUI"
133 >
134 <span class="text-p-sm">
135 Conditions for this SLA were created from
136 <a :href="deskUrl" target="_blank" class="underline">desk</a>
137 which are not compatible with this UI, you will need to
138 recreate the conditions here if you want to manage and add new
139 conditions from this UI.
140 </span>
141 <Button
142 :label="__('I understand, add conditions')"
143 variant="subtle"
144 theme="gray"
145 @click="useNewUI = true"
146 />
147 </div>
148 <SlaAssignmentConditions
149 :conditions="slaData.condition_json"
150 v-if="useNewUI"
151 />
152 </div>
153 </div>
154 </div>
155 <hr class="my-8" />
156 <div>
157 <div class="flex flex-col gap-1">
158 <span class="text-md-semibold text-ink-gray-8">
159 {{ __("Valid From") }}
160 </span>
161 <span class="text-p-sm text-ink-gray-6">
162 {{ __("Choose how long this SLA policy will be active.") }}
163 </span>
164 </div>
165 <div class="mt-3.5 flex gap-5 flex-col md:flex-row">
166 <div class="w-full space-y-1.5">
167 <FormLabel :label="__('From date')" for="from_date" />
168 <DatePicker
169 v-model="slaData.start_date"
170 variant="subtle"
171 placeholder="11/01/2025"
172 class="w-full"
173 id="from_date"
174 @update:model-value="validateSlaData('start_date')"
175 :format="getDateFormat()"
176 >
177 <template #prefix>
178 <LucideCalendar class="size-4" />
179 </template>
180 </DatePicker>
181 <ErrorMessage :message="slaDataErrors.start_date" />
182 </div>
183 <div class="w-full space-y-1.5">
184 <FormLabel :label="__('To date')" for="to_date" />
185 <DatePicker
186 v-model="slaData.end_date"
187 variant="subtle"
188 placeholder="25/12/2025"
189 class="w-full"
190 id="to_date"
191 @update:model-value="validateSlaData('end_date')"
192 :format="getDateFormat()"
193 >
194 <template #prefix>
195 <LucideCalendar class="size-4" />
196 </template>
197 </DatePicker>
198 <ErrorMessage :message="slaDataErrors.end_date" />
199 </div>
200 </div>
201 </div>
202 <hr class="my-8" />
203 <div>
204 <div class="flex flex-col gap-1">
205 <span class="text-md-semibold text-ink-gray-8">
206 {{ __("Response and Resolution") }}
207 </span>
208 <span class="text-p-sm text-ink-gray-6">
209 {{
210 __(
211 "Add time targets around support milestones like first reply and resolution times."
212 )
213 }}
214 </span>
215 </div>
216 <div class="mt-5">
217 <!-- <div class="flex gap-6">
218 <div
219 class="flex items-center gap-2"
220 @click="onApplySlaForChange(false)"
221 >
222 <input
223 name="apply_sla_for"
224 :checked="!slaData.apply_sla_for_resolution"
225 type="radio"
226 />
227 <div class="select-none text-ink-gray-6 text-sm-medium">
228 Apply SLA for response time
229 </div>
230 </div>
231 <div
232 class="flex items-center gap-2"
233 @click="onApplySlaForChange(true)"
234 >
235 <input
236 name="apply_sla_for"
237 :checked="slaData.apply_sla_for_resolution"
238 type="radio"
239 />
240 <div class="select-none text-ink-gray-6 text-sm-medium">
241 Apply SLA for response time and resolution time
242 </div>
243 </div>
244 </div> -->
245 <div class="mt-5">
246 <SlaPriorityList />
247 </div>
248 </div>
249 </div>
250 <hr class="my-8" />
251 <div>
252 <div class="flex flex-col gap-1">
253 <span class="text-md-semibold text-ink-gray-8">
254 {{ __("Status Details") }}
255 </span>
256 <span class="text-p-sm text-ink-gray-6">
257 {{
258 __(
259 "Set the default status assigned when a ticket is created, and the status to apply when a ticket is reopened. If not specified, the default status from OD Settings will be used."
260 )
261 }}
262 </span>
263 </div>
264 <div class="mt-5">
265 <SlaStatusList />
266 </div>
267 </div>
268 <hr class="my-8" />
269 <SlaHolidays />
270 </div>
271 </template>
272 </SettingsLayoutBase>
273 <ConfirmDialog
274 v-model="showConfirmDialog.show"
275 :title="showConfirmDialog.title"
276 :message="showConfirmDialog.message"
277 :onConfirm="showConfirmDialog.onConfirm"
278 :onCancel="() => (showConfirmDialog.show = false)"
279 />
280</template>
281
282<script setup lang="ts">
283import LucideInfo from "~icons/lucide/info";
284import ConfirmDialog from "@/components/ConfirmDialog.vue";
285import {
286 resetSlaDataErrors,
287 slaActiveScreen,
288 slaData,
289 slaDataErrors,
290 validateSlaData,
291} from "@/stores/sla";
292import { convertToConditions, getDateFormat } from "@/utils";
293import {
294 Badge,
295 Button,
296 Checkbox,
297 createResource,
298 DatePicker,
299 ErrorMessage,
300 FormLabel,
301 HoverCard,
302 LoadingIndicator,
303 Switch,
304 toast,
305} from "frappe-ui";
306import { inject, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
307import SlaAssignmentConditions from "./SlaAssignmentConditions.vue";
308import SlaHolidays from "./SlaHolidays.vue";
309import SlaPriorityList from "./SlaPriorityList.vue";
310import SlaStatusList from "./SlaStatusList.vue";
311import { disableSettingModalOutsideClick } from "../settingsModal";
312import { useOnboarding } from "@framework/ui";
313import { __ } from "@/translation";
314import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
315import { SlaPolicyListResourceSymbol } from "@/types";
316import { ODServiceLevelAgreement } from "@/types/doctypes";
317
318const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};
319
320const showConfirmDialog = ref({
321 show: false,
322 title: "",
323 message: "",
324 onConfirm: () => {},
325});
326const isDirty = ref(false);
327const initialData = ref(null);
328
329const useNewUI = ref(true);
330const isOldSla = ref(false);
331
332const slaPolicyList = inject(SlaPolicyListResourceSymbol);
333const deskUrl = `${window.location.origin}/app/od-service-level-agreement/${slaActiveScreen.value.data?.name}`;
334
335const getSlaData = createResource({
336 url: "frappe.client.get",
337 params: {
338 doctype: "OD Service Level Agreement",
339 name: slaActiveScreen.value.data?.name,
340 },
341 onSuccess(data: ODServiceLevelAgreement) {
342 let condition_json;
343 try {
344 condition_json = JSON.parse(data.condition_json || "[]");
345 } catch (error) {
346 toast.error(
347 "Assignment conditions are invalid or corrupt, recreate the conditions."
348 );
349 condition_json = [];
350 }
351
352 const newData = {
353 ...data,
354 loading: false,
355 condition_json: condition_json,
356 };
357 slaData.value = newData;
358 slaData.value.apply_sla_for_resolution = true;
359 initialData.value = JSON.stringify(newData);
360 const conditionsAvailable = slaData.value.condition?.length > 0;
361 const conditionsJsonAvailable = slaData.value.condition_json?.length > 0;
362 if (conditionsAvailable && !conditionsJsonAvailable) {
363 useNewUI.value = false;
364 isOldSla.value = true;
365 } else {
366 useNewUI.value = true;
367 isOldSla.value = false;
368 }
369 },
370});
371
372if (slaActiveScreen.value.data && slaActiveScreen.value.fetchData) {
373 slaData.value.loading = true;
374 getSlaData.submit();
375} else {
376 disableSettingModalOutsideClick.value = true;
377}
378
379const goBack = () => {
380 const confirmDialogInfo = {
381 show: true,
382 title: __("Unsaved changes"),
383 message: __(
384 "Are you sure you want to go back? Unsaved changes will be lost."
385 ),
386 onConfirm: goBack,
387 };
388 if (isDirty.value && !showConfirmDialog.value.show) {
389 showConfirmDialog.value = confirmDialogInfo;
390 return;
391 }
392 if (!slaActiveScreen.value.data && !showConfirmDialog.value.show) {
393 showConfirmDialog.value = confirmDialogInfo;
394 return;
395 }
396 // Workaround fix for settings modal not closing after going back
397 setTimeout(() => {
398 slaActiveScreen.value = {
399 screen: "list",
400 data: null,
401 fetchData: true,
402 };
403 }, 250);
404 showConfirmDialog.value.show = false;
405};
406
407const saveSla = () => {
408 const validationErrors = validateSlaData(undefined, !useNewUI.value);
409
410 if (Object.values(validationErrors).some((error) => error)) {
411 toast.error(
412 __("Invalid fields, check if all are filled in and values are correct.")
413 );
414 return;
415 }
416
417 if (slaActiveScreen.value.data) {
418 if (isOldSla.value && useNewUI.value) {
419 showConfirmDialog.value = {
420 show: true,
421 title: __("Confirm overwrite"),
422 message: __(
423 "Your old conditions will be overwritten. Are you sure you want to save?"
424 ),
425 onConfirm: () => {
426 updateSla();
427 showConfirmDialog.value.show = false;
428 },
429 };
430 return;
431 }
432 updateSla();
433 } else {
434 createSla();
435 }
436};
437
438const createSla = () => {
439 const defaultTicketStatus = slaData.value.default_ticket_status || null;
440 const ticketReopenStatus = slaData.value.reopen_ticket_status || null;
441 slaPolicyList?.insert.submit(
442 {
443 ...slaData.value,
444 default_ticket_status: defaultTicketStatus,
445 ticket_reopen_status: ticketReopenStatus,
446 condition: convertToConditions({
447 conditions: slaData.value.condition_json,
448 fieldPrefix: "doc",
449 }),
450 condition_json: JSON.stringify(slaData.value.condition_json),
451 },
452 {
453 onSuccess(data) {
454 toast.success(__("SLA policy created successfully."));
455 slaActiveScreen.value.data = data;
456 slaActiveScreen.value.screen = "view";
457 getSlaData.submit({
458 doctype: "OD Service Level Agreement",
459 name: data.name,
460 });
461 updateOnboardingStep?.("setup_sla", true);
462 },
463 }
464 );
465};
466
467const updateSla = () => {
468 const defaultTicketStatus = slaData.value.default_ticket_status || null;
469 const ticketReopenStatus = slaData.value.reopen_ticket_status || null;
470 slaPolicyList?.setValue.submit(
471 {
472 ...slaData.value,
473 name: slaActiveScreen.value.data?.name,
474 default_ticket_status: defaultTicketStatus,
475 ticket_reopen_status: ticketReopenStatus,
476 condition: useNewUI.value
477 ? convertToConditions({
478 conditions: slaData.value.condition_json,
479 fieldPrefix: "doc",
480 })
481 : slaData.value.condition,
482 condition_json: useNewUI.value
483 ? JSON.stringify(slaData.value.condition_json)
484 : null,
485 },
486 {
487 onSuccess() {
488 getSlaData.submit();
489 toast.success(__("SLA policy updated successfully."));
490 slaPolicyList.reload();
491 },
492 }
493 );
494};
495
496const toggleEnabled = () => {
497 slaData.value.enabled = !slaData.value.enabled;
498};
499
500const toggleDefaultSla = (value: boolean) => {
501 slaData.value.default_sla = value;
502 if (value) {
503 slaData.value.enabled = true;
504 }
505};
506
507const onApplySlaForChange = (applyForResolution: boolean) => {
508 slaData.value.apply_sla_for_resolution = applyForResolution;
509 nextTick(() => {
510 validateSlaData("priorities");
511 });
512};
513
514watch(
515 slaData,
516 (newVal) => {
517 if (!initialData.value) return;
518 isDirty.value = JSON.stringify(newVal) != initialData.value;
519 if (isDirty.value) {
520 disableSettingModalOutsideClick.value = true;
521 } else {
522 disableSettingModalOutsideClick.value = false;
523 }
524 },
525 { deep: true }
526);
527
528const beforeUnloadHandler = (event) => {
529 if (!isDirty.value) return;
530 event.preventDefault();
531 event.returnValue = true;
532};
533onMounted(() => {
534 addEventListener("beforeunload", beforeUnloadHandler);
535});
536
537onUnmounted(() => {
538 removeEventListener("beforeunload", beforeUnloadHandler);
539 resetSlaDataErrors();
540 disableSettingModalOutsideClick.value = false;
541});
542</script>
543<style scoped>
544input[type="radio"] {
545 display: flex;
546 align-items: center;
547 gap: 8px;
548 cursor: pointer;
549 border: 2px solid #c5c2c2;
550 border-radius: 50%;
551 outline: none;
552 transition: all 0.2s ease;
553 background-color: white;
554}
555
556input[type="radio"]:checked {
557 background-color: black;
558 border: 2px solid #000;
559}
560
561input[type="radio"]:checked::after {
562 content: "";
563 background-color: #fff;
564}
565
566input[type="radio"]:focus {
567 outline: none !important;
568 box-shadow: none !important;
569}
570</style>
571