feat: create note & task from call window

This commit is contained in:
Shariq Ansari 2025-01-17 16:35:52 +05:30
parent e6a5da4c1b
commit b212dac043
7 changed files with 443 additions and 138 deletions

View File

@ -6,76 +6,89 @@ from frappe.model.document import Document
class CRMCallLog(Document): class CRMCallLog(Document):
@staticmethod @staticmethod
def default_list_data(): def default_list_data():
columns = [ columns = [
{ {
'label': 'From', "label": "From",
'type': 'Link', "type": "Link",
'key': 'caller', "key": "caller",
'options': 'User', "options": "User",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'To', "label": "To",
'type': 'Link', "type": "Link",
'key': 'receiver', "key": "receiver",
'options': 'User', "options": "User",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'Type', "label": "Type",
'type': 'Select', "type": "Select",
'key': 'type', "key": "type",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'Status', "label": "Status",
'type': 'Select', "type": "Select",
'key': 'status', "key": "status",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'Duration', "label": "Duration",
'type': 'Duration', "type": "Duration",
'key': 'duration', "key": "duration",
'width': '6rem', "width": "6rem",
}, },
{ {
'label': 'From (number)', "label": "From (number)",
'type': 'Data', "type": "Data",
'key': 'from', "key": "from",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'To (number)', "label": "To (number)",
'type': 'Data', "type": "Data",
'key': 'to', "key": "to",
'width': '9rem', "width": "9rem",
}, },
{ {
'label': 'Created On', "label": "Created On",
'type': 'Datetime', "type": "Datetime",
'key': 'creation', "key": "creation",
'width': '8rem', "width": "8rem",
}, },
] ]
rows = [ rows = [
"name", "name",
"caller", "caller",
"receiver", "receiver",
"type", "type",
"status", "status",
"duration", "duration",
"from", "from",
"to", "to",
"note", "note",
"recording_url", "recording_url",
"reference_doctype", "reference_doctype",
"reference_docname", "reference_docname",
"creation", "creation",
] ]
return {'columns': columns, 'rows': rows} return {"columns": columns, "rows": rows}
def has_link(self, doctype, name):
for link in self.links:
if link.link_doctype == doctype and link.link_name == name:
return True
def link_with_reference_doc(self, reference_doctype, reference_name):
if self.has_link(reference_doctype, reference_name):
return
self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name})
self.save(ignore_permissions=True)
@frappe.whitelist() @frappe.whitelist()
def create_lead_from_call_log(call_log): def create_lead_from_call_log(call_log):
@ -85,15 +98,17 @@ def create_lead_from_call_log(call_log):
lead.lead_owner = frappe.session.user lead.lead_owner = frappe.session.user
lead.save(ignore_permissions=True) lead.save(ignore_permissions=True)
frappe.db.set_value("CRM Call Log", call_log.get("name"), { frappe.db.set_value(
"reference_doctype": "CRM Lead", "CRM Call Log",
"reference_docname": lead.name call_log.get("name"),
}) {"reference_doctype": "CRM Lead", "reference_docname": lead.name},
)
if call_log.get("note"): if call_log.get("note"):
frappe.db.set_value("FCRM Note", call_log.get("note"), { frappe.db.set_value(
"reference_doctype": "CRM Lead", "FCRM Note",
"reference_docname": lead.name call_log.get("note"),
}) {"reference_doctype": "CRM Lead", "reference_docname": lead.name},
)
return lead.name return lead.name

View File

@ -17,3 +17,32 @@ def is_call_integration_enabled():
@frappe.whitelist() @frappe.whitelist()
def set_default_calling_medium(medium): def set_default_calling_medium(medium):
return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium) return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium)
@frappe.whitelist()
def create_and_add_note_to_call_log(call_sid, content):
"""Add note to call log based on call sid."""
note = frappe.get_doc(
{
"doctype": "FCRM Note",
"content": content,
}
).insert(ignore_permissions=True)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("FCRM Note", note.name)
@frappe.whitelist()
def create_and_add_task_to_call_log(call_sid, task):
"""Add task to call log based on call sid."""
_task = frappe.get_doc(
{
"doctype": "CRM Task",
"title": task.get("title"),
"description": task.get("description"),
}
).insert(ignore_permissions=True)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("CRM Task", _task.name)

View File

@ -48,7 +48,7 @@ def handle_request(**kwargs):
request_log.status = "Failed" request_log.status = "Failed"
request_log.error = frappe.get_traceback() request_log.error = frappe.get_traceback()
frappe.db.rollback() frappe.db.rollback()
frappe.log_error(title="Error while creating call record") frappe.log_error(title="Error while creating/updating call record")
frappe.db.commit() frappe.db.commit()
finally: finally:
request_log.save(ignore_permissions=True) request_log.save(ignore_permissions=True)

View File

@ -12,6 +12,45 @@ const timer = ref(null)
const updatedTime = ref('0:00') const updatedTime = ref('0:00')
function startCounter() { function startCounter() {
updatedTime.value = getTime()
}
function start() {
timer.value = setInterval(() => startCounter(), 1000)
}
function stop() {
clearInterval(timer.value)
let output = updatedTime.value
hours.value = 0
minutes.value = 0
seconds.value = 0
updatedTime.value = '0:00'
return output
}
function getTime(_seconds = 0) {
if (_seconds) {
if (typeof _seconds === 'string') {
_seconds = parseInt(_seconds)
}
seconds.value = _seconds
if (seconds.value >= 60) {
minutes.value = Math.floor(seconds.value / 60)
seconds.value = seconds.value % 60
} else {
minutes.value = 0
}
if (minutes.value >= 60) {
hours.value = Math.floor(minutes.value / 60)
minutes.value = minutes.value % 60
} else {
hours.value = 0
}
}
if (seconds.value === 59) { if (seconds.value === 59) {
seconds.value = 0 seconds.value = 0
minutes.value = minutes.value + 1 minutes.value = minutes.value + 1
@ -36,22 +75,8 @@ function startCounter() {
} }
} }
updatedTime.value = hoursCount + minutesCount + ':' + secondsCount return hoursCount + minutesCount + ':' + secondsCount
} }
function start() { defineExpose({ start, stop, getTime, updatedTime })
timer.value = setInterval(() => startCounter(), 1000)
}
function stop() {
clearInterval(timer.value)
let output = updatedTime.value
hours.value = 0
minutes.value = 0
seconds.value = 0
updatedTime.value = '0:00'
return output
}
defineExpose({ start, stop, updatedTime })
</script> </script>

View File

@ -53,7 +53,7 @@ import {
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { createToast } from '@/utils' import { createToast } from '@/utils'
import { FormControl, call } from 'frappe-ui' import { FormControl, call } from 'frappe-ui'
import { ref, watch } from 'vue' import { nextTick, ref, watch } from 'vue'
const { setMakeCall } = globalStore() const { setMakeCall } = globalStore()
@ -116,20 +116,25 @@ async function setDefaultCallingMedium() {
}) })
} }
watch([twilioEnabled, exotelEnabled], ([twilioValue, exotelValue]) => { watch(
if (twilioValue) { [twilioEnabled, exotelEnabled],
twilio.value.setup() ([twilioValue, exotelValue]) =>
callMedium.value = 'Twilio' nextTick(() => {
} if (twilioValue) {
twilio.value.setup()
callMedium.value = 'Twilio'
}
if (exotelValue) { if (exotelValue) {
exotel.value.setup() exotel.value.setup()
callMedium.value = 'Exotel' callMedium.value = 'Exotel'
} }
if (twilioValue || exotelValue) { if (twilioValue || exotelValue) {
callMedium.value = 'Twilio' callMedium.value = 'Twilio'
setMakeCall(makeCall) setMakeCall(makeCall)
} }
}) }),
{ immediate: true },
)
</script> </script>

View File

@ -32,18 +32,21 @@
<span>{{ __(callStatus) }}</span> <span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'"> <span v-if="callStatus == 'Call ended'">
<span> · </span> <span> · </span>
<span>{{ counterUp?.updatedTime }}</span> <span>{{ callDuration }}</span>
</span> </span>
</div> </div>
<div v-else>{{ __(callStatus) }}</div> <div v-else>{{ __(callStatus) }}</div>
</div> </div>
<div <div
v-show="showCallPopup" v-show="showCallPopup"
ref="callPopup" class="fixed z-20 w-[280px] min-h-44 flex gap-2 flex-col rounded-lg bg-surface-gray-7 p-4 pt-2.5 text-ink-gray-2 shadow-2xl"
class="fixed z-20 w-[280px] min-h-44 flex gap-2 cursor-move select-none flex-col rounded-lg bg-surface-gray-7 p-4 pt-2.5 text-ink-gray-2 shadow-2xl"
:style="style" :style="style"
@click.stop
> >
<div class="header flex items-center justify-between gap-1 text-base"> <div
ref="callPopupHeader"
class="header flex items-center justify-between gap-1 text-base cursor-move select-none"
>
<div class="flex gap-2 items-center truncate"> <div class="flex gap-2 items-center truncate">
<div v-if="showNote || showTask" class="flex items-center gap-3"> <div v-if="showNote || showTask" class="flex items-center gap-3">
<Avatar <Avatar
@ -60,7 +63,7 @@
</div> </div>
<div class="flex flex-col gap-1 text-base leading-4"> <div class="flex flex-col gap-1 text-base leading-4">
<div class="font-medium"> <div class="font-medium">
{{ contact.full_name ?? phoneNumber }} {{ contact?.full_name ?? phoneNumber }}
</div> </div>
<div class="text-ink-gray-6"> <div class="text-ink-gray-6">
<div v-if="callStatus == 'In progress'"> <div v-if="callStatus == 'In progress'">
@ -81,7 +84,7 @@
<span>{{ __(callStatus) }}</span> <span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'"> <span v-if="callStatus == 'Call ended'">
<span> · </span> <span> · </span>
<span>{{ counterUp?.updatedTime }}</span> <span>{{ callDuration }}</span>
</span> </span>
</div> </div>
<div v-else>{{ __(callStatus) }}</div> <div v-else>{{ __(callStatus) }}</div>
@ -105,7 +108,7 @@
<span>{{ __(callStatus) }}</span> <span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'"> <span v-if="callStatus == 'Call ended'">
<span> · </span> <span> · </span>
<span>{{ counterUp?.updatedTime }}</span> <span>{{ callDuration }}</span>
</span> </span>
</div> </div>
<div v-else>{{ __(callStatus) }}</div> <div v-else>{{ __(callStatus) }}</div>
@ -123,8 +126,18 @@
</Button> </Button>
</div> </div>
<div class="body flex-1"> <div class="body flex-1">
<div v-if="showNote" class="h-[294px] text-base">{{ note }}</div> <div v-if="showNote">
<div v-else-if="showTask" class="h-[294px] text-base">{{ task }}</div> <TextEditor
variant="ghost"
ref="content"
editor-class="prose-sm h-[290px] text-ink-white overflow-auto mt-1"
:bubbleMenu="true"
:content="note"
@change="(val) => (note = val)"
:placeholder="__('Take a note...')"
/>
</div>
<TaskPanel ref="taskRef" v-else-if="showTask" :task="task" />
<div v-else class="flex items-center gap-3"> <div v-else class="flex items-center gap-3">
<Avatar <Avatar
v-if="contact?.image" v-if="contact?.image"
@ -139,10 +152,16 @@
<AvatarIcon class="size-4" /> <AvatarIcon class="size-4" />
</div> </div>
<div v-if="contact?.full_name" class="flex flex-col gap-1"> <div v-if="contact?.full_name" class="flex flex-col gap-1">
<div class="text-lg font-medium leading-5">{{ contact.full_name }}</div> <div class="text-lg font-medium leading-5">
<div class="text-base text-ink-gray-6 leading-4">{{ phoneNumber }}</div> {{ contact.full_name }}
</div>
<div class="text-base text-ink-gray-6 leading-4">
{{ phoneNumber }}
</div>
</div>
<div v-else class="text-lg font-medium leading-5">
{{ phoneNumber }}
</div> </div>
<div v-else class="text-lg font-medium leading-5">{{ phoneNumber }}</div>
</div> </div>
</div> </div>
<div class="footer flex justify-between gap-2"> <div class="footer flex justify-between gap-2">
@ -166,12 +185,23 @@
</template> </template>
</Button> </Button>
</div> </div>
<Button <div class="flex gap-2">
@click="closeCallPopup" <Button
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5" v-if="callStatus == 'Call ended' || callStatus == 'No answer'"
:label="__('Close')" @click="closeCallPopup"
size="md" class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
/> :label="__('Close')"
size="md"
/>
<Button
v-if="(note && note != '<p></p>') || task.title"
@click="save"
class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3"
variant="solid"
:label="__('Save')"
size="md"
/>
</div>
</div> </div>
</div> </div>
<CountUpTimer ref="counterUp" /> <CountUpTimer ref="counterUp" />
@ -182,8 +212,9 @@ import AvatarIcon from '@/components/Icons/AvatarIcon.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue' import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue' import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue' import TaskIcon from '@/components/Icons/TaskIcon.vue'
import TaskPanel from './TaskPanel.vue'
import CountUpTimer from '@/components/CountUpTimer.vue' import CountUpTimer from '@/components/CountUpTimer.vue'
import { Avatar, Button, call } from 'frappe-ui' import { TextEditor, Avatar, Button, call } from 'frappe-ui'
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts' import { contactsStore } from '@/stores/contacts'
import { useDraggable, useWindowSize } from '@vueuse/core' import { useDraggable, useWindowSize } from '@vueuse/core'
@ -192,7 +223,7 @@ import { ref, computed, onBeforeUnmount } from 'vue'
const { getContact, getLeadContact } = contactsStore() const { getContact, getLeadContact } = contactsStore()
const { $socket } = globalStore() const { $socket } = globalStore()
const callPopup = ref(null) const callPopupHeader = ref(null)
const showCallPopup = ref(false) const showCallPopup = ref(false)
const showSmallCallPopup = ref(false) const showSmallCallPopup = ref(false)
@ -207,7 +238,7 @@ function toggleCallPopup() {
const { width, height } = useWindowSize() const { width, height } = useWindowSize()
let { style } = useDraggable(callPopup, { let { style } = useDraggable(callPopupHeader, {
initialValue: { x: width.value - 350, y: height.value - 250 }, initialValue: { x: width.value - 350, y: height.value - 250 },
preventDefault: true, preventDefault: true,
}) })
@ -245,7 +276,22 @@ function showNoteWindow() {
} }
} }
const task = ref('') function createNote() {
call('crm.integrations.api.create_and_add_note_to_call_log', {
call_sid: callData.value.CallSid,
content: note.value,
})
note.value = ''
}
const task = ref({
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
})
const showTask = ref(false) const showTask = ref(false)
@ -259,8 +305,24 @@ function showTaskWindow() {
} }
} }
function createTask() {
call('crm.integrations.api.create_and_add_task_to_call_log', {
call_sid: callData.value.CallSid,
task: task.value,
})
task.value = {
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}
}
function updateWindowHeight(condition) { function updateWindowHeight(condition) {
let top = parseInt(callPopup.value.style.top) let callPopup = callPopupHeader.value.parentElement
let top = parseInt(callPopup.style.top)
let updatedTop = 0 let updatedTop = 0
updatedTop = condition ? top - 224 : top + 224 updatedTop = condition ? top - 224 : top + 224
@ -269,7 +331,7 @@ function updateWindowHeight(condition) {
updatedTop = 10 updatedTop = 10
} }
callPopup.value.style.top = updatedTop + 'px' callPopup.style.top = updatedTop + 'px'
} }
function makeOutgoingCall(number) { function makeOutgoingCall(number) {
@ -304,9 +366,23 @@ function closeCallPopup() {
showCallPopup.value = false showCallPopup.value = false
showSmallCallPopup.value = false showSmallCallPopup.value = false
note.value = '' note.value = ''
task.value = '' task.value = {
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}
} }
function save() {
if (note.value) createNote()
if (task.value.title) createTask()
}
const callDuration = ref('00:00')
function updateStatus(data) { function updateStatus(data) {
// outgoing call // outgoing call
if ( if (
@ -339,6 +415,9 @@ function updateStatus(data) {
data.Status == 'completed' data.Status == 'completed'
) { ) {
counterUp.value.stop() counterUp.value.stop()
callDuration.value = getTime(
data['Legs[0][OnCallDuration]'] || data.DialCallDuration,
)
return 'Call ended' return 'Call ended'
} }
@ -348,12 +427,16 @@ function updateStatus(data) {
data.Direction == 'incoming' && data.Direction == 'incoming' &&
data.Status == 'busy' data.Status == 'busy'
) { ) {
phoneNumber.value = data.From || data.CallFrom
return 'Incoming call' return 'Incoming call'
} else if ( } else if (
data.EventType == 'Terminal' &&
data.Direction == 'incoming' && data.Direction == 'incoming' &&
data.Status == 'free' (data.EventType == 'Terminal' || data.CallType == 'completed') &&
(data.Status == 'free' || data.DialCallStatus == 'completed')
) { ) {
callDuration.value = counterUp.value.getTime(
data['Legs[0][OnCallDuration]'] || data.DialCallDuration,
)
return 'Call ended' return 'Call ended'
} }
} }
@ -376,4 +459,8 @@ defineExpose({ makeOutgoingCall, setup })
.blink { .blink {
animation: blink 1s ease-in-out 6; animation: blink 1s ease-in-out 6;
} }
:deep(.ProseMirror) {
caret-color: var(--ink-white);
}
</style> </style>

View File

@ -0,0 +1,144 @@
<template>
<div class="h-[294px] text-base">
<FormControl
type="text"
variant="ghost"
class="mb-2 title"
v-model="task.title"
:placeholder="__('Schedule a task...')"
/>
<TextEditor
variant="ghost"
ref="content"
editor-class="prose-sm h-[150px] text-ink-white overflow-auto"
:bubbleMenu="true"
:content="task.description"
@change="(val) => (task.description = val)"
:placeholder="__('Add description...')"
/>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
<Button
:label="task.status"
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
>
<template #prefix>
<TaskStatusIcon :status="task.status" />
</template>
</Button>
</Dropdown>
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button
:label="task.priority"
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
>
<template #prefix>
<TaskPriorityIcon :priority="task.priority" />
</template>
</Button>
</Dropdown>
</div>
<Link
class="user"
:value="getUser(task.assigned_to).full_name"
doctype="User"
@change="(option) => (task.assigned_to = option)"
:placeholder="__('John Doe')"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2 !h-4 !w-4" :user="task.assigned_to" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<DateTimePicker
class="datepicker w-36"
v-model="task.due_date"
:placeholder="__('01/04/2024 11:30 PM')"
:formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none"
/>
</div>
</div>
</template>
<script setup>
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { taskStatusOptions, taskPriorityOptions, getFormat } from '@/utils'
import { TextEditor, Dropdown, Tooltip, DateTimePicker } from 'frappe-ui'
const props = defineProps({
task: {
type: Object,
default: () => ({
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}),
},
})
const { getUser } = usersStore()
function updateTaskStatus(status) {
props.task.status = status
}
function updateTaskPriority(priority) {
props.task.priority = priority
}
</script>
<style scoped>
:deep(.title input) {
background-color: var(--surface-gray-7);
caret-color: var(--ink-white);
color: var(--ink-white);
outline: none;
border: none;
padding: 0;
}
:deep(.datepicker input) {
background-color: var(--surface-gray-6);
caret-color: var(--ink-white);
color: var(--ink-white);
outline: none;
border: none;
}
:deep(.title input:focus),
:deep(.datepicker input:focus) {
border: none;
outline: none;
box-shadow: none;
}
:deep(.user button) {
background-color: var(--surface-gray-6);
border: none;
color: var(--ink-white);
}
:deep(.user button:hover) {
background-color: var(--surface-gray-5);
border: none;
}
:deep(.user button:focus) {
box-shadow: none;
outline: none;
}
</style>