1<template>
2 <Dialog v-model:open="show" bare @after-leave="resetCallLog">
3 <template #default>
4 <div class="px-4 pt-5 bg-surface-elevation-2 sm:px-6">
5 <div class="flex items-center justify-between mb-5">
6 <div class="flex items-center gap-2">
7 <h3 class="text-2xl-semibold leading-6 text-ink-gray-9">
8 {{ __(dialogOptions.title) || __("Untitled") }}
9 </h3>
10 <Badge v-if="isDirty" :label="__('Not Saved')" theme="amber" />
11 </div>
12 <div class="flex items-center gap-1">
13 <Button
14 variant="ghost"
15 class="w-7"
16 @click="show = false"
17 icon="lucide-x"
18 />
19 </div>
20 </div>
21 <div class="flex flex-col gap-4">
22 <div class="grid grid-cols-2 gap-4">
23 <div class="flex flex-col gap-1.5">
24 <FormLabel :label="__('Type')" required />
25 <Select
26 v-model="callLog.type"
27 :options="callLogTypeOptions"
28 :placeholder="__('Select')"
29 />
30 <ErrorMessage :message="errors.type" />
31 </div>
32 <div class="flex flex-col gap-1.5">
33 <FormLabel :label="__('To')" required />
34 <FormControl
35 v-model="callLog.to"
36 type="text"
37 placeholder="+18596748596"
38 />
39 <ErrorMessage :message="errors.to" />
40 </div>
41 </div>
42 <div class="grid grid-cols-2 gap-4">
43 <div class="flex flex-col gap-1.5">
44 <FormLabel :label="__('From')" required />
45 <FormControl
46 v-model="callLog.from"
47 type="text"
48 placeholder="+19138276548"
49 />
50 <ErrorMessage :message="errors.from" />
51 </div>
52 <div class="flex flex-col gap-1.5">
53 <FormLabel :label="__('Status')" required />
54 <Select
55 v-model="callLog.status"
56 :options="callLogStatusOptions"
57 :placeholder="__('Select')"
58 ></Select>
59 <ErrorMessage :message="errors.status" />
60 </div>
61 </div>
62 <div class="grid grid-cols-2 gap-4">
63 <div class="flex flex-col gap-1.5">
64 <FormLabel :label="__('Duration')" required />
65 <FormControl
66 v-model="callLog.duration"
67 type="number"
68 :placeholder="__('Duration')"
69 :description="__('Call duration in seconds')"
70 />
71 <ErrorMessage :message="errors.duration" />
72 </div>
73 <div
74 class="flex flex-col gap-1.5"
75 v-if="callLog.type === 'Incoming'"
76 >
77 <Link
78 :value="callLog.receiver"
79 :doctype="'User'"
80 :placeholder="__('Select')"
81 :label="__('Call Received By')"
82 @change="(data) => (callLog.receiver = data)"
83 />
84 </div>
85 <div
86 class="flex flex-col gap-1.5"
87 v-if="callLog.type === 'Outgoing'"
88 >
89 <Link
90 :value="callLog.caller"
91 :doctype="'User'"
92 :placeholder="__('Select')"
93 :label="__('Caller')"
94 @change="(data) => (callLog.caller = data)"
95 />
96 </div>
97 <div class="flex flex-col gap-1.5">
98 <Link
99 :value="callLog.ticket"
100 :doctype="'OD Ticket'"
101 :placeholder="__('Select')"
102 :label="__('Ticket')"
103 :show-description="true"
104 @change="(data) => (callLog.ticket = String(data))"
105 />
106 </div>
107 </div>
108 </div>
109 </div>
110 <div class="px-4 pt-4 pb-7 sm:px-6">
111 <div class="space-y-2">
112 <Button
113 class="w-full"
114 v-for="action in dialogOptions.actions"
115 :key="action.label"
116 v-bind="action"
117 :label="__(action.label)"
118 :loading="loading"
119 />
120 </div>
121 </div>
122 </template>
123 </Dialog>
124</template>
125
126<script setup lang="ts">
127import { useAuthStore } from "@/stores/auth";
128import { getRandom } from "@/utils";
129import {
130 createResource,
131 Dialog,
132 ErrorMessage,
133 FormControl,
134 FormLabel,
135 Select,
136 toast,
137 type DialogAction,
138 type DialogSize,
139} from "frappe-ui";
140import { computed, ref, watch } from "vue";
141import { __ } from "@/translation";
142
143const show = defineModel<boolean>();
144const editMode = ref(false);
145const loading = ref(false);
146const isDirty = ref(false);
147const originalCallLog = ref("");
148const emit = defineEmits(["afterInsert"]);
149const { user } = useAuthStore();
150
151const errors = ref({
152 type: "",
153 to: "",
154 from: "",
155 status: "",
156 duration: "",
157});
158
159const callLog = ref({
160 receiver: "",
161 caller: "",
162 type: "",
163 to: "",
164 from: "",
165 status: "",
166 duration: 0,
167 ticket: "",
168});
169
170const props = defineProps({
171 callLogId: {
172 type: String,
173 default: "",
174 },
175 ticketId: {
176 type: String,
177 default: "",
178 },
179});
180
181const callLogData = createResource({
182 url: "telephony.api.get_call_log",
183 onSuccess(data) {
184 const linkedTicket = data.links.find(
185 (link) => link.link_doctype == "OD Ticket"
186 );
187 callLog.value = {
188 receiver: data.receiver,
189 caller: data.caller,
190 type: data.type,
191 to: data.to,
192 from: data.from,
193 status: data.status,
194 duration: data.duration || 0,
195 ticket: String(linkedTicket?.link_name || ""),
196 };
197 originalCallLog.value = JSON.stringify(callLog.value);
198 },
199 auto: false,
200});
201
202const callLogTypeOptions = [
203 { label: "", value: "" },
204 { label: __("Incoming"), value: "Incoming" },
205 { label: __("Outgoing"), value: "Outgoing" },
206];
207
208const callLogStatusOptions = [
209 { label: "", value: "" },
210 { label: __("Completed"), value: "Completed" },
211 { label: __("Busy"), value: "Busy" },
212 { label: __("Failed"), value: "Failed" },
213 { label: __("Initiated"), value: "Initiated" },
214 { label: __("Queued"), value: "Queued" },
215 { label: __("Canceled"), value: "Canceled" },
216 { label: __("Ringing"), value: "Ringing" },
217 { label: __("No Answer"), value: "No Answer" },
218 { label: __("In Progress"), value: "In Progress" },
219];
220
221const dialogOptions = computed<{
222 title: string;
223 size: DialogSize;
224 actions: DialogAction[];
225}>(() => ({
226 title: !editMode.value ? __("New Call Log") : __("Edit Call Log"),
227 size: "xl",
228 actions: [
229 {
230 label: editMode.value ? __("Save") : __("Create"),
231 variant: "solid",
232 onClick: () => {
233 if (editMode.value) {
234 updateCallLog();
235 } else {
236 createCallLog();
237 }
238 },
239 disabled: !isDirty.value,
240 },
241 ],
242}));
243
244const resetCallLog = () => {
245 callLog.value = {
246 receiver: "",
247 caller: "",
248 type: "",
249 to: "",
250 from: "",
251 status: "",
252 duration: 0,
253 ticket: "",
254 };
255 originalCallLog.value = JSON.stringify(callLog.value);
256 errors.value = {
257 type: "",
258 to: "",
259 from: "",
260 status: "",
261 duration: "",
262 };
263 loading.value = false;
264};
265
266function updateCallLog() {
267 validateCallLog();
268 if (Object.values(errors.value).some((error) => error)) {
269 toast.error(__("Please fill all required fields"));
270 return;
271 }
272 loading.value = true;
273 const links = [];
274 if (callLog.value.ticket) {
275 links.push({
276 link_doctype: "OD Ticket",
277 link_name: callLog.value.ticket,
278 });
279 }
280 // Copy old links
281 callLogData.data.links.forEach((link) => {
282 if (link.link_doctype == "OD Ticket") {
283 return;
284 }
285 links.push(link);
286 });
287
288 if (callLog.value.receiver == "@me") {
289 callLog.value.receiver = user;
290 }
291 if (callLog.value.caller == "@me") {
292 callLog.value.caller = user;
293 }
294 createResource({
295 url: "frappe.client.set_value",
296 params: {
297 doctype: "TP Call Log",
298 name: props.callLogId,
299 fieldname: {
300 ...callLog.value,
301 links,
302 },
303 },
304 auto: true,
305 onSuccess(doc) {
306 loading.value = false;
307 handleCallLogUpdate(doc);
308 },
309 });
310}
311
312function createCallLog() {
313 validateCallLog();
314 if (Object.values(errors.value).some((error) => error)) {
315 toast.error(__("Please fill all required fields"));
316 return;
317 }
318 loading.value = true;
319 const links = [];
320 if (callLog.value.ticket) {
321 links.push({
322 link_doctype: "OD Ticket",
323 link_name: callLog.value.ticket,
324 });
325 }
326 if (callLog.value.receiver == "@me") {
327 callLog.value.receiver = user;
328 }
329 if (callLog.value.caller == "@me") {
330 callLog.value.caller = user;
331 }
332 createResource({
333 url: "telephony.api.create_call_log",
334 params: {
335 id: getRandom(12),
336 telephony_medium: "Manual",
337 from_number: callLog.value.from,
338 to_number: callLog.value.to,
339 duration: callLog.value.duration,
340 status: callLog.value.status,
341 call_type: callLog.value.type,
342 caller: callLog.value.caller,
343 receiver: callLog.value.receiver,
344 ticket: callLog.value.ticket,
345 links,
346 },
347 auto: true,
348 onSuccess(doc) {
349 loading.value = false;
350 handleCallLogUpdate(doc);
351 },
352 onError() {
353 loading.value = false;
354 },
355 });
356}
357
358function handleCallLogUpdate(doc) {
359 show.value = false;
360 emit("afterInsert", doc);
361}
362
363const validateCallLog = () => {
364 if (!callLog.value.type) {
365 errors.value.type = __("Type is required");
366 } else {
367 errors.value.type = "";
368 }
369
370 if (!callLog.value.to) {
371 errors.value.to = __("To is required");
372 } else if (!/^\+?\d{8,15}$/.test(callLog.value.to)) {
373 errors.value.to = __("Please enter a valid phone number");
374 } else {
375 errors.value.to = "";
376 }
377
378 if (!callLog.value.from) {
379 errors.value.from = __("From is required");
380 } else if (!/^\+?\d{8,15}$/.test(callLog.value.from)) {
381 errors.value.from = __("Please enter a valid phone number");
382 } else {
383 errors.value.from = "";
384 }
385
386 if (!callLog.value.status) {
387 errors.value.status = __("Status is required");
388 } else {
389 errors.value.status = "";
390 }
391
392 if (!callLog.value.duration) {
393 errors.value.duration = __("Duration is required");
394 } else {
395 errors.value.duration = "";
396 }
397};
398
399watch(
400 callLog,
401 (newValue) => {
402 isDirty.value = JSON.stringify(newValue) !== originalCallLog.value;
403 },
404 { deep: true }
405);
406
407watch(show, (newValue) => {
408 if (newValue && props.callLogId) {
409 editMode.value = true;
410 callLogData.submit({
411 name: props.callLogId,
412 });
413 } else {
414 editMode.value = false;
415 }
416 if (newValue && props.ticketId) {
417 callLog.value.ticket = String(props.ticketId);
418 }
419});
420</script>
421