1<template>
2 <SettingsLayoutBase
3 :back-label="holidayData?.holiday_list_name || __('New Business Holiday')"
4 :on-back="goBack"
5 :dirty="isDirty"
6 >
7 <template #header-actions>
8 <Button
9 :label="__('Save')"
10 theme="gray"
11 variant="solid"
12 @click="saveHoliday()"
13 :disabled="Boolean(!isDirty && holidayListActiveScreen.data)"
14 :loading="
15 holidayList?.list.loading ||
16 holidayList?.setValue.loading ||
17 renameHolidayResource.loading ||
18 getHolidayData.loading
19 "
20 />
21 </template>
22 <template #content>
23 <div v-if="!holidayData.loading" class="h-full">
24 <div class="flex items-center gap-2 mt-2">
25 <span class="text-sm">
26 There are in total <b>{{ holidayData.holidays.length }}</b> holidays
27 in this list</span
28 >
29 </div>
30 <hr class="mb-8 mt-2" />
31 <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
32 <div>
33 <FormControl
34 type="text"
35 size="sm"
36 variant="subtle"
37 :placeholder="__('Name')"
38 :label="__('Name')"
39 v-model="holidayData.holiday_list_name"
40 required
41 maxlength="50"
42 @change="validateHoliday('holiday_list_name')"
43 />
44 <ErrorMessage
45 :message="holidayDataErrors.holiday_list_name"
46 class="mt-2"
47 />
48 </div>
49 <FormControl
50 type="textarea"
51 size="sm"
52 variant="subtle"
53 :placeholder="__('Description')"
54 :label="__('Description')"
55 v-model="holidayData.description"
56 maxlength="140"
57 />
58 </div>
59 <hr class="my-8" />
60 <div>
61 <div class="flex flex-col gap-1">
62 <span class="text-md-semibold text-ink-gray-8">{{
63 __("Valid From")
64 }}</span>
65 <span class="text-p-sm text-ink-gray-6">
66 {{ __("Choose the duration of this holiday list.") }}
67 </span>
68 </div>
69 <div class="mt-3.5 flex gap-5 flex-col md:flex-row">
70 <div class="w-full space-y-1.5">
71 <FormLabel :label="__('From date')" for="from_date" required />
72 <DatePicker
73 v-model="holidayData.from_date"
74 variant="subtle"
75 placeholder="11/01/2025"
76 class="w-full"
77 id="from_date"
78 :format="getDateFormat()"
79 @update:model-value="updateDuration('from_date')"
80 >
81 <template #prefix>
82 <LucideCalendar class="size-4" />
83 </template>
84 </DatePicker>
85 <ErrorMessage
86 :message="
87 holidayDataErrors.from_date || holidayDataErrors.dateRange
88 "
89 />
90 </div>
91 <div class="w-full space-y-1.5">
92 <FormLabel :label="__('To date')" for="to_date" required />
93 <DatePicker
94 v-model="holidayData.to_date"
95 variant="subtle"
96 placeholder="25/12/2025"
97 class="w-full"
98 id="to_date"
99 :format="getDateFormat()"
100 @update:model-value="updateDuration('to_date')"
101 >
102 <template #prefix>
103 <LucideCalendar class="size-4" />
104 </template>
105 </DatePicker>
106 <ErrorMessage :message="holidayDataErrors.to_date" />
107 </div>
108 </div>
109 </div>
110 <hr class="my-8" />
111 <div>
112 <div class="flex flex-col gap-1">
113 <div class="text-md-semibold text-ink-gray-8">
114 {{ __("Recurring Holidays") }}
115 </div>
116 <div class="text-p-sm text-ink-gray-6">
117 {{ __("Add recurring holidays such as weekends.") }}
118 </div>
119 </div>
120 <div class="mt-5">
121 <RecurringHolidaysList
122 :holidayData="holidayData"
123 :holidays="holidayData.recurring_holidays"
124 />
125 </div>
126 </div>
127 <hr class="my-8" />
128 <div>
129 <div class="flex justify-between items-center">
130 <div class="flex justify-between flex-col gap-1">
131 <span class="text-md-semibold text-ink-gray-8">
132 {{ __("Holidays") }}
133 </span>
134 <div class="text-p-sm text-ink-gray-6">
135 {{
136 __(
137 "Add holidays here to make sure they’re excluded from SLA calculations."
138 )
139 }}
140 </div>
141 </div>
142 <TabButtons
143 :options="[
144 {
145 value: 'calendar',
146 label: __('Calendar'),
147 icon: 'lucide-calendar',
148 },
149 {
150 value: 'list',
151 label: __('List'),
152 icon: 'lucide-list',
153 },
154 ]"
155 v-model="holidayListView"
156 />
157 </div>
158 <div class="mt-5">
159 <HolidaysTableView v-if="holidayListView === 'list'" />
160 <HolidaysCalendarView v-else />
161 </div>
162 <div class="mt-2.5 flex justify-between items-center">
163 <Button
164 variant="subtle"
165 :label="__('Add Holiday')"
166 @click="dialog.show = true"
167 icon-left="lucide-plus"
168 />
169 <!-- Indicators -->
170 <div class="flex gap-4" v-if="holidayListView === 'calendar'">
171 <div class="gap-1 flex items-center">
172 <span class="bg-surface-yellow-2 size-4 rounded-1" />
173 <span class="text-sm text-ink-gray-6">{{
174 __("Holidays")
175 }}</span>
176 </div>
177 <div class="gap-1 flex items-center">
178 <span class="bg-surface-gray-2 size-4 rounded-1" />
179 <span class="text-sm text-ink-gray-6">{{
180 __("Recurring holidays")
181 }}</span>
182 </div>
183 </div>
184 </div>
185 </div>
186 </div>
187 </template>
188 </SettingsLayoutBase>
189 <AddHolidayModal v-model="dialog" />
190 <ConfirmDialog
191 v-model="showConfirmDialog"
192 :title="__('Unsaved changes')"
193 :message="
194 __('Are you sure you want to go back? Unsaved changes will be lost.')
195 "
196 :onConfirm="goBack"
197 :onCancel="() => (showConfirmDialog = false)"
198 />
199</template>
200
201<script setup lang="ts">
202import {
203 holidayData,
204 holidayDataErrors,
205 holidayListActiveScreen,
206 updateWeeklyOffDates,
207 validateHoliday,
208} from "@/stores/holidayList";
209import {
210 Button,
211 createResource,
212 DatePicker,
213 FormControl,
214 LoadingIndicator,
215 TabButtons,
216 toast,
217} from "frappe-ui";
218import { inject, onMounted, onUnmounted, ref, watch } from "vue";
219import HolidaysTableView from "./HolidaysTableView.vue";
220import RecurringHolidaysList from "./RecurringHolidaysList.vue";
221
222import ConfirmDialog from "@/components/ConfirmDialog.vue";
223import { slaActiveScreen } from "@/stores/sla";
224import { getDateFormat, htmlToText } from "@/utils";
225import { dayjs, FormLabel } from "frappe-ui";
226import {
227 disableSettingModalOutsideClick,
228 setActiveSettingsTab,
229} from "../settingsModal";
230import HolidaysCalendarView from "./HolidaysCalendarView.vue";
231import AddHolidayModal from "./Modals/AddHolidayModal.vue";
232import { __ } from "@/translation";
233import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
234import { HolidayListResourceSymbol } from "@/types";
235import { ODServiceHolidayList } from "@/types/doctypes";
236
237const dialog = ref({
238 show: false,
239 holiday_date: new Date(),
240 description: "",
241 editing: null,
242});
243
244const isDirty = ref(false);
245const initialData = ref(null);
246const holidayListView = ref("calendar");
247
248const showConfirmDialog = ref(false);
249
250const holidayList = inject(HolidayListResourceSymbol);
251
252const getHolidayData = createResource({
253 url: "frappe.client.get",
254 params: {
255 doctype: "OD Service Holiday List",
256 name: holidayListActiveScreen.value.data?.name,
257 },
258 onSuccess(data: ODServiceHolidayList) {
259 holidayData.value = data;
260 initialData.value = JSON.stringify(data);
261 },
262 transform(data: ODServiceHolidayList) {
263 for (let holiday of data.holidays) {
264 holiday.description = htmlToText(holiday.description);
265 }
266 data.recurring_holidays = JSON.parse(data.recurring_holidays || "[]");
267 return data;
268 },
269});
270
271const renameHolidayResource = createResource({
272 url: "frappe.client.rename_doc",
273 makeParams() {
274 return {
275 doctype: "OD Service Holiday List",
276 old_name: holidayData.value.name,
277 new_name: holidayData.value.holiday_list_name,
278 };
279 },
280});
281
282if (holidayListActiveScreen.value.data?.name) {
283 holidayData.value.loading = true;
284 getHolidayData.fetch();
285} else {
286 initialData.value = JSON.stringify(holidayData.value);
287}
288
289const updateDuration = (key) => {
290 validateHoliday(key);
291 if (
292 !holidayDataErrors.value.dateRange ||
293 holidayDataErrors.value.dateRange === ""
294 ) {
295 updateWeeklyOffDates();
296 }
297};
298
299const goBack = () => {
300 if (isDirty.value && !showConfirmDialog.value) {
301 showConfirmDialog.value = true;
302 return;
303 }
304 if (holidayListActiveScreen.value.previousScreen) {
305 setActiveSettingsTab("SLA Policies");
306
307 slaActiveScreen.value = {
308 screen: "view",
309 data: { name: holidayListActiveScreen.value.previousScreen.data },
310 fetchData: false,
311 };
312 }
313 showConfirmDialog.value = false;
314 // Workaround fix for settings modal not closing after going back
315 setTimeout(() => {
316 holidayListActiveScreen.value = {
317 screen: "list",
318 data: null,
319 };
320 }, 250);
321};
322
323const saveHoliday = () => {
324 const validationErrors = validateHoliday();
325 if (Object.values(validationErrors).some((error) => error)) {
326 toast.error(
327 __("Invalid fields, check if all are filled in and values are correct.")
328 );
329 return;
330 }
331
332 if (holidayListActiveScreen.value.data) {
333 updateHoliday();
334 } else {
335 createHoliday();
336 }
337};
338
339const createHoliday = () => {
340 const holidays = holidayData.value.holidays.map((holiday) => {
341 return {
342 ...holiday,
343 holiday_date: dayjs(holiday.holiday_date).format("YYYY-MM-DD"),
344 };
345 });
346 holidayList?.insert.submit(
347 {
348 holiday_list_name: holidayData.value.holiday_list_name,
349 description: holidayData.value.description,
350 from_date: holidayData.value.from_date,
351 to_date: holidayData.value.to_date,
352 holidays: holidays,
353 recurring_holidays: JSON.stringify(holidayData.value.recurring_holidays),
354 },
355 {
356 onSuccess(data) {
357 toast.success(__("Holiday list created successfully."));
358 holidayListActiveScreen.value.data = data;
359 holidayListActiveScreen.value.screen = "view";
360 getHolidayData.submit({
361 doctype: "OD Service Holiday List",
362 name: data.name,
363 });
364 },
365 }
366 );
367};
368
369const updateHoliday = async () => {
370 const holidays = holidayData.value.holidays.map((holiday) => {
371 return {
372 ...holiday,
373 holiday_date: dayjs(holiday.holiday_date).format("YYYY-MM-DD"),
374 };
375 });
376
377 await holidayList?.setValue.submit(
378 {
379 name: holidayListActiveScreen.value?.data?.name,
380 holiday_list_name: holidayData.value.holiday_list_name,
381 description: holidayData.value.description,
382 from_date: holidayData.value.from_date,
383 to_date: holidayData.value.to_date,
384 holidays: holidays,
385 recurring_holidays: JSON.stringify(holidayData.value.recurring_holidays),
386 },
387 {
388 onError(err) {
389 const message = err?.messages?.[0];
390 toast.error(
391 message || __("Some error occurred while updating holiday list")
392 );
393 },
394 }
395 );
396
397 if (holidayData.value.name !== holidayData.value.holiday_list_name) {
398 await renameHolidayResource.submit().catch(async (err) => {
399 const error =
400 err?.messages?.[0] ||
401 __("Some error occurred while renaming holiday list");
402 toast.error(error);
403 // Reset holiday data to previous state
404 await getHolidayData.reload();
405 });
406
407 // Update the active screen data to reflect the new name
408 holidayListActiveScreen.value.data.name =
409 holidayData.value.holiday_list_name;
410
411 getHolidayData.submit({
412 doctype: "OD Service Holiday List",
413 name: holidayData.value.holiday_list_name,
414 });
415 } else {
416 await getHolidayData.reload();
417 }
418
419 toast.success(__("Holiday list updated successfully."));
420 holidayList?.reload();
421};
422
423watch(
424 holidayData,
425 (newVal) => {
426 if (!initialData.value) return;
427 isDirty.value = JSON.stringify(newVal) != initialData.value;
428 if (isDirty.value) {
429 disableSettingModalOutsideClick.value = true;
430 } else {
431 disableSettingModalOutsideClick.value = false;
432 }
433 },
434 { deep: true }
435);
436
437const beforeUnloadHandler = (event) => {
438 if (!isDirty.value) return;
439 event.preventDefault();
440 event.returnValue = true;
441};
442
443onMounted(() => {
444 addEventListener("beforeunload", beforeUnloadHandler);
445});
446
447onUnmounted(() => {
448 removeEventListener("beforeunload", beforeUnloadHandler);
449 holidayDataErrors.value = {
450 holiday_list_name: "",
451 from_date: "",
452 to_date: "",
453 };
454 disableSettingModalOutsideClick.value = false;
455});
456</script>
457
458<style scoped>
459input[type="radio"] {
460 display: flex;
461 align-items: center;
462 gap: 8px;
463 cursor: pointer;
464 border: 2px solid #c5c2c2;
465 border-radius: 50%;
466 outline: none;
467 transition: all 0.2s ease;
468 background-color: white;
469}
470
471input[type="radio"]:checked {
472 background-color: black;
473 border: 2px solid #000;
474}
475
476input[type="radio"]:checked::after {
477 content: "";
478 background-color: #fff;
479}
480
481input[type="radio"]:focus {
482 outline: none !important;
483 box-shadow: none !important;
484}
485</style>
486