1
0
forked from test/crm

Merge pull request #170 from shariquerik/calllog-modal

fix: Show call log details in modal
This commit is contained in:
Shariq Ansari 2024-05-04 16:42:54 +05:30 committed by GitHub
commit 65f6692a12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 307 additions and 323 deletions

View File

@ -69,64 +69,14 @@ class CRMCallLog(Document):
"duration",
"from",
"to",
"note",
"recording_url",
"reference_doctype",
"reference_docname",
"creation",
]
return {'columns': columns, 'rows': rows}
@frappe.whitelist()
def get_call_log(name):
doc = frappe.get_doc("CRM Call Log", name)
doc = doc.as_dict()
if doc.reference_docname and doc.reference_doctype == "CRM Lead":
doc.lead = doc.reference_docname
doc.lead_name = frappe.db.get_value("CRM Lead", doc.reference_docname, "lead_name")
if doc.note:
note = frappe.db.get_values("FCRM Note", doc.note, ["title", "content"])[0]
doc.note_doc = {
"name": doc.note,
"title": note[0],
"content": note[1]
}
def get_contact(number):
c = frappe.db.get_value("Contact", {"mobile_no": number}, ["full_name", "image"], as_dict=True)
if c:
return [c.full_name, c.image]
return [None, None]
def get_lead_contact(number):
l = frappe.db.get_value("CRM Lead", {"mobile_no": number, "converted": 0}, ["lead_name", "image"], as_dict=True)
if l:
return [l.lead_name, l.image]
return [None, None]
def get_user(user):
u = frappe.db.get_value("User", user, ["full_name", "user_image"], as_dict=True)
if u:
return [u.full_name, u.user_image]
return [None, None]
if doc.type == "Incoming":
doc.caller = {
"label": get_contact(doc.get("from"))[0] or get_lead_contact(doc.get("from"))[0] or "Unknown",
"image": get_contact(doc.get("from"))[1] or get_lead_contact(doc.get("from"))[1]
}
doc.receiver = {
"label": get_user(doc.get("receiver"))[0],
"image": get_user(doc.get("receiver"))[1]
}
else:
doc.caller = {
"label": get_user(doc.get("caller"))[0],
"image": get_user(doc.get("caller"))[1]
}
doc.receiver = {
"label": get_contact(doc.get("to"))[0] or get_lead_contact(doc.get("to"))[0] or "Unknown",
"image": get_contact(doc.get("to"))[1] or get_lead_contact(doc.get("to"))[1]
}
return doc
@frappe.whitelist()
def create_lead_from_call_log(call_log):
lead = frappe.new_doc("CRM Lead")

View File

@ -0,0 +1,23 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_3668_69185)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.25 8C14.25 11.4518 11.4518 14.25 8 14.25C4.54822 14.25 1.75 11.4518 1.75 8C1.75 4.54822 4.54822 1.75 8 1.75C11.4518 1.75 14.25 4.54822 14.25 8ZM15.25 8C15.25 12.0041 12.0041 15.25 8 15.25C3.99594 15.25 0.75 12.0041 0.75 8C0.75 3.99594 3.99594 0.75 8 0.75C12.0041 0.75 15.25 3.99594 15.25 8ZM11.2909 5.98482C11.4666 5.77175 11.4363 5.45663 11.2232 5.28096C11.0101 5.1053 10.695 5.13561 10.5193 5.34868L7.07001 9.53239L5.72845 7.79857C5.55946 7.58018 5.24543 7.54012 5.02703 7.70911C4.80863 7.8781 4.76858 8.19214 4.93756 8.41053L6.66217 10.6394C6.7552 10.7596 6.89788 10.831 7.04988 10.8334C7.20188 10.8357 7.3467 10.7688 7.4434 10.6515L11.2909 5.98482Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3668_69185">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@ -3,10 +3,7 @@
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({
name: 'Call Log',
params: { callLogId: row.name },
}),
onRowClick: (row) => emit('showCallLog', row.name),
selectable: options.selectable,
showTooltip: options.showTooltip,
resizeColumn: options.resizeColumn,

View File

@ -0,0 +1,233 @@
<template>
<Dialog v-model="show">
<template #body-title>
<div class="flex items-center gap-3">
<h3 class="text-2xl font-semibold leading-6 text-gray-900">
{{ __('Call Details') }}
</h3>
</div>
</template>
<template #body-content>
<div class="flex flex-col gap-3.5">
<div
v-for="field in detailFields"
:key="field.name"
class="flex gap-2 text-base text-gray-800"
>
<div class="grid size-7 place-content-center">
<component :is="field.icon" />
</div>
<div class="flex min-h-7 w-full items-center gap-2">
<div
v-if="field.name == 'receiver'"
class="flex items-center gap-1"
>
<Avatar
:image="field.value.caller.image"
:label="field.value.caller.label"
size="sm"
/>
<div class="ml-1 flex flex-col gap-1">
{{ field.value.caller.label }}
</div>
<FeatherIcon
name="arrow-right"
class="mx-1 h-4 w-4 text-gray-600"
/>
<Avatar
:image="field.value.receiver.image"
:label="field.value.receiver.label"
size="sm"
/>
<div class="ml-1 flex flex-col gap-1">
{{ field.value.receiver.label }}
</div>
</div>
<Tooltip v-else-if="field.tooltip" :text="field.tooltip">
{{ field.value }}
</Tooltip>
<div class="w-full" v-else-if="field.name == 'recording_url'">
<audio
class="audio-control w-full"
controls
:src="field.value"
></audio>
</div>
<div
class="max-h-30 min-h-16 w-full cursor-pointer overflow-hidden rounded border px-2 py-1.5 text-base text-gray-700"
v-else-if="field.name == 'note'"
@click="() => (showNoteModal = true)"
>
<div
v-if="field.value?.title"
:class="[field.value?.content ? 'mb-1 font-bold' : '']"
v-html="field.value?.title"
/>
<div v-if="field.value?.content" v-html="field.value?.content" />
</div>
<div v-else :class="field.color ? `text-${field.color}-600` : ''">
{{ field.value }}
</div>
<div v-if="field.link">
<ArrowUpRightIcon
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600 hover:text-gray-800"
@click="() => field.link()"
/>
</div>
</div>
</div>
</div>
</template>
<template
v-if="callLog.type.label == 'Incoming' && !callLog.reference_docname"
#actions
>
<Button
class="w-full"
variant="solid"
:label="__('Create lead')"
@click="createLead"
/>
</template>
</Dialog>
<NoteModal v-model="showNoteModal" :note="callNoteDoc?.doc" />
</template>
<script setup>
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import Dealsicon from '@/components/Icons/DealsIcon.vue'
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import CheckCircleIcon from '@/components/Icons/CheckCircleIcon.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { FeatherIcon, Avatar, Tooltip, createDocumentResource, call } from 'frappe-ui'
import { ref, computed, h, watch } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
callLog: {
type: Object,
default: {},
},
})
const show = defineModel()
const showNoteModal = ref(false)
const router = useRouter()
const callNoteDoc = ref(null)
const detailFields = computed(() => {
let details = [
{
icon: h(FeatherIcon, {
name: props.callLog.type.icon,
class: 'h-3.5 w-3.5',
}),
name: 'type',
value: props.callLog.type.label + ' Call',
},
{
icon: ContactsIcon,
name: 'receiver',
value: {
receiver: props.callLog.receiver,
caller: props.callLog.caller,
},
},
{
icon:
props.callLog.reference_doctype == 'CRM Lead' ? LeadsIcon : Dealsicon,
name: 'reference_doctype',
value: props.callLog.reference_doctype == 'CRM Lead' ? 'Lead' : 'Deal',
link: () => {
if (props.callLog.reference_doctype == 'CRM Lead') {
router.push({
name: 'Lead',
params: { leadId: props.callLog.reference_docname },
})
} else {
router.push({
name: 'Deal',
params: { dealId: props.callLog.reference_docname },
})
}
},
},
{
icon: CalendarIcon,
name: 'creation',
value: props.callLog.creation.label,
tooltip: props.callLog.creation.label,
},
{
icon: DurationIcon,
name: 'duration',
value: props.callLog.duration.label,
},
{
icon: CheckCircleIcon,
name: 'status',
value: props.callLog.status.label,
color: props.callLog.status.color,
},
{
icon: h(FeatherIcon, {
name: 'play-circle',
class: 'h-4 w-4 mt-2',
}),
name: 'recording_url',
value: props.callLog.recording_url,
},
{
icon: NoteIcon,
name: 'note',
value: callNoteDoc.value?.doc,
},
]
return details.filter((detail) => detail.value)
})
function createLead() {
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
call_log: props.callLog,
}).then((d) => {
if (d) {
router.push({ name: 'Lead', params: { leadId: d } })
}
})
}
watch(show, (val) => {
if (val) {
callNoteDoc.value = createDocumentResource({
doctype: 'FCRM Note',
name: props.callLog.note,
fields: ['title', 'content'],
cache: ['note', props.callLog.note],
auto: true,
})
}
})
</script>
<style scoped>
.audio-control {
height: 36px;
outline: none;
border-radius: 10px;
cursor: pointer;
background-color: rgb(237, 237, 237);
}
audio::-webkit-media-controls-panel {
background-color: rgb(237, 237, 237) !important;
}
.audio-control::-webkit-media-controls-panel {
background-color: white;
}
</style>

View File

@ -149,7 +149,7 @@ watch(
nextTick(() => {
title.value.el.focus()
_note.value = { ...props.note }
if (_note.value.title) {
if (_note.value.title || _note.value.content) {
editMode.value = true
}
})

View File

@ -1,258 +0,0 @@
<template>
<LayoutHeader v-if="callLog.data">
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Button
v-if="callLog.data.type == 'Incoming' && !callLog.data.lead"
variant="solid"
:label="__('Create lead')"
@click="createLead"
>
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
</Button>
</template>
</LayoutHeader>
<div v-if="callLog.data" class="max-w-lg p-6">
<div class="pb-3 text-base font-medium">{{ __('Call details') }}</div>
<div class="mb-3 flex flex-col gap-4 rounded-lg border p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FeatherIcon
:name="
callLog.data.type == 'Incoming'
? 'phone-incoming'
: 'phone-outgoing'
"
class="h-4 w-4 text-gray-600"
/>
<div class="font-medium">
{{
callLog.data.type == 'Incoming'
? __('Inbound Call')
: __('Outbound Call')
}}
</div>
</div>
<div>
<Badge
:variant="'subtle'"
:theme="statusColorMap[callLog.data.status]"
size="md"
:label="__(statusLabelMap[callLog.data.status])"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<Avatar
:image="callLog.data.caller.image"
:label="callLog.data.caller.label"
size="xl"
/>
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ __(callLog.data.caller.label) }}
</div>
<div class="text-xs text-gray-600">
{{ callLog.data.from }}
</div>
</div>
<FeatherIcon name="arrow-right" class="mx-2 h-5 w-5 text-gray-600" />
<Avatar
:image="callLog.data.receiver.image"
:label="callLog.data.receiver.label"
size="xl"
/>
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ __(callLog.data.receiver.label) }}
</div>
<div class="text-xs text-gray-600">
{{ callLog.data.to }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<DurationIcon class="h-4 w-4 text-gray-600" />
<div class="text-sm text-gray-600">{{ __('Duration') }}</div>
<div class="text-sm">{{ callLog.data.duration }}</div>
</div>
<Tooltip :text="dateFormat(callLog.data.creation, dateTooltipFormat)">
<div class="text-sm text-gray-600">
{{ __(timeAgo(callLog.data.creation)) }}
</div>
</Tooltip>
</div>
</div>
<div v-if="callLog.data.recording_url" class="mt-6">
<div class="mb-3 text-base font-medium">{{ __('Call recording') }}</div>
<div class="flex items-center justify-between rounded border shadow-sm">
<audio
class="audio-control"
controls
:src="callLog.data.recording_url"
></audio>
</div>
</div>
<div v-if="callLog.data.note" class="mt-6">
<div class="mb-3 text-base font-medium">{{ __('Call note') }}</div>
<div
class="flex h-56 cursor-pointer flex-col gap-3 rounded border p-4 shadow-sm"
@click="showNoteModal = true"
>
<div class="truncate text-lg font-medium">
{{ callLog.data.note_doc.title }}
</div>
<TextEditor
v-if="callLog.data.note_doc.content"
:content="callLog.data.note_doc.content"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden"
/>
</div>
</div>
<div v-if="callLog.data.lead" class="mt-6">
<div class="mb-3 text-base font-medium">{{ __('Lead') }}</div>
<Button
variant="outline"
:route="{ name: 'Lead', params: { leadId: callLog.data.lead } }"
:label="callLog.data.lead_name"
class="p-4"
>
<template #prefix><Avatar :label="callLog.data.lead_name" /></template>
</Button>
</div>
</div>
<NoteModal
v-model="showNoteModal"
:note="callLog.data?.note_doc"
@after="updateNote"
/>
</template>
<script setup>
import LayoutHeader from '@/components/LayoutHeader.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import {
dateFormat,
timeAgo,
dateTooltipFormat,
secondsToDuration,
} from '@/utils'
import {
TextEditor,
Avatar,
call,
Tooltip,
createResource,
Breadcrumbs,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
callLogId: {
type: String,
required: true,
},
})
const showNoteModal = ref(false)
const callLog = createResource({
url: 'crm.fcrm.doctype.crm_call_log.crm_call_log.get_call_log',
auto: true,
cache: ['callLog', props.callLogId],
params: {
name: props.callLogId,
},
transform: (doc) => {
doc.duration = secondsToDuration(doc.duration)
return doc
},
})
async function updateNote(_note) {
if (_note.title || _note.content) {
let d = await call('frappe.client.set_value', {
doctype: 'FCRM Note',
name: callLog.data?.note,
fieldname: _note,
})
if (d.name) {
callLog.reload()
}
}
}
function createLead() {
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
call_log: callLog.data,
}).then((d) => {
if (d) {
callLog.reload()
router.push({ name: 'Lead', params: { leadId: d } })
}
})
}
const breadcrumbs = computed(() => {
let items = [{ label: __('Call Logs'), route: { name: 'Call Logs' } }]
items.push({
label: callLog.data?.caller.label,
route: { name: 'Call Log', params: { callLogId: props.callLogId } },
})
return items
})
const statusLabelMap = {
Completed: 'Completed',
Initiated: 'Initiated',
Busy: 'Declined',
Failed: 'Failed',
Queued: 'Queued',
Cancelled: 'Cancelled',
Ringing: 'Ringing',
'No Answer': 'Missed Call',
'In Progress': 'In Progress',
}
const statusColorMap = {
Completed: 'green',
Busy: 'orange',
Failed: 'red',
Initiated: 'gray',
Queued: 'gray',
Cancelled: 'gray',
Ringing: 'gray',
'No Answer': 'red',
'In Progress': 'blue',
}
</script>
<style scoped>
.audio-control {
width: 100%;
height: 40px;
outline: none;
border: none;
background: none;
cursor: pointer;
}
.audio-control::-webkit-media-controls-panel {
background-color: white;
}
</style>

View File

@ -31,6 +31,7 @@
rowCount: callLogs.data.row_count,
totalCount: callLogs.data.total_count,
}"
@showCallLog="showCallLog"
@loadMore="() => loadMore++"
@columnWidthUpdated="() => triggerResize++"
@updatePageCount="(count) => (updatedPageCount = count)"
@ -47,6 +48,11 @@
<span>{{ __('No Logs Found') }}</span>
</div>
</div>
<CallLogModal
v-model="showCallLogModal"
v-model:reloadCallLogs="callLogs"
:callLog="callLog"
/>
</template>
<script setup>
@ -55,6 +61,7 @@ import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import ViewControls from '@/components/ViewControls.vue'
import CallLogsListView from '@/components/ListViews/CallLogsListView.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue'
import {
secondsToDuration,
dateFormat,
@ -139,6 +146,44 @@ const rows = computed(() => {
})
})
const showCallLogModal = ref(false)
const callLog = ref({
name: '',
caller: '',
receiver: '',
duration: '',
type: '',
status: '',
from: '',
to: '',
note: '',
recording_url: '',
reference_doctype: '',
reference_docname: '',
creation: '',
})
function showCallLog(name) {
let d = rows.value?.find((row) => row.name === name)
callLog.value = {
name: d.name,
caller: d.caller,
receiver: d.receiver,
duration: d.duration,
type: d.type,
status: d.status,
from: d.from,
to: d.to,
note: d.note,
recording_url: d.recording_url,
reference_doctype: d.reference_doctype,
reference_docname: d.reference_docname,
creation: d.creation,
}
showCallLogModal.value = true
}
const statusLabelMap = {
Completed: 'Completed',
Initiated: 'Initiated',

View File

@ -72,12 +72,6 @@ const routes = [
component: () => import('@/pages/CallLogs.vue'),
meta: { scrollPos: { top: 0, left: 0 } },
},
{
path: '/call-logs/:callLogId',
name: 'Call Log',
component: () => import('@/pages/CallLog.vue'),
props: true,
},
{
path: '/email-templates',
name: 'Email Templates',