Merge pull request #14 from shariquerik/refactor-activities

This commit is contained in:
Shariq Ansari 2023-09-27 21:07:49 +05:30 committed by GitHub
commit 6be84ae867
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 632 additions and 543 deletions

View File

@ -21,29 +21,39 @@ def get_lead(name):
frappe.throw(_("Lead not found"), frappe.DoesNotExistError)
lead = lead.pop()
return lead
@frappe.whitelist()
def get_activities(name):
get_docinfo('', "CRM Lead", name)
docinfo = frappe.response["docinfo"]
activities = get_activities(lead, docinfo)
return { **lead, 'activities': activities }
def get_activities(doc, docinfo):
lead_fields_meta = frappe.get_meta("CRM Lead").fields
doc = frappe.db.get_values("CRM Lead", name, ["creation", "owner", "created_as_deal"])[0]
created_as_deal = doc[2]
is_lead = False if created_as_deal else True
activities = [{
"activity_type": "creation",
"creation": doc.creation,
"owner": doc.owner,
"data": "created this lead",
"creation": doc[0],
"owner": doc[1],
"data": "created this " + ("deal" if created_as_deal else "lead"),
"is_lead": is_lead,
}]
docinfo.versions.reverse()
for version in docinfo.versions:
data = json.loads(version.data)
if not data.get("changed"):
continue
field_option = None
if change := data.get("changed")[0]:
field_label, field_option = next(((f.label, f.options) for f in lead_fields_meta if f.fieldname == change[0]), None)
activity_type = "changed"
field_label = next((f.label for f in lead_fields_meta if f.fieldname == change[0]), None)
if field_label == "Lead Owner" and (created_as_deal or not is_lead):
field_label = "Deal Owner"
data = {
"field": change[0],
"field_label": field_label,
@ -59,6 +69,9 @@ def get_activities(doc, docinfo):
"field_label": field_label,
"value": change[2],
}
if field_label == "Is Deal" and change[2] and is_lead:
activity_type = "deal"
is_lead = False
elif change[1] and not change[2]:
activity_type = "removed"
data = {
@ -72,15 +85,8 @@ def get_activities(doc, docinfo):
"creation": version.creation,
"owner": version.owner,
"data": data,
}
activities.append(activity)
for comment in docinfo.comments:
activity = {
"activity_type": "comment",
"creation": comment.creation,
"owner": comment.owner,
"data": comment.content,
"is_lead": is_lead,
"options": field_option,
}
activities.append(activity)
@ -98,9 +104,44 @@ def get_activities(doc, docinfo):
"bcc": communication.bcc,
"read_by_recipient": communication.read_by_recipient,
},
"is_lead": is_lead,
}
activities.append(activity)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities
return activities
def handle_multiple_versions(versions):
activities = []
grouped_versions = []
old_version = None
for version in versions:
is_version = version["activity_type"] in ["changed", "added", "removed"]
if not is_version:
activities.append(version)
if not old_version:
old_version = version
if is_version: grouped_versions.append(version)
continue
if is_version and old_version.get("owner") and version["owner"] == old_version["owner"]:
grouped_versions.append(version)
else:
if grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
grouped_versions = []
if is_version: grouped_versions.append(version)
old_version = version
if version == versions[-1] and grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
return activities
def parse_grouped_versions(versions):
version = versions[0]
if len(versions) == 1:
return version
other_versions = versions[1:]
version["other_versions"] = other_versions
return version

View File

@ -14,6 +14,7 @@
"middle_name",
"last_name",
"is_deal",
"created_as_deal",
"column_break_izjs",
"lead_name",
"gender",
@ -251,12 +252,19 @@
{
"fieldname": "section_break_jyxr",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "created_as_deal",
"fieldtype": "Check",
"hidden": 1,
"label": "Created as Deal"
}
],
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-08-25 19:21:22.778067",
"modified": "2023-09-27 18:54:18.196159",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead",

View File

@ -1,26 +1,28 @@
<template>
<div class="px-10 py-5 flex items-center justify-between font-medium text-lg">
<div class="flex items-center h-7 text-xl font-semibold">{{ title }}</div>
<Button v-if="title == 'Calls'" variant="solid" @click="emit('makeCall')">
<PhoneIcon class="w-4 h-4" />
</Button>
<div class="flex items-center justify-between px-10 py-5 text-lg font-medium">
<div class="flex h-7 items-center text-xl font-semibold text-gray-800">
{{ title }}
</div>
<Button
v-else-if="title == 'Notes'"
v-if="title == 'Calls'"
variant="solid"
@click="emit('makeNote')"
@click="makeCall(lead.data.mobile_no)"
>
<FeatherIcon name="plus" class="w-4 h-4" />
<PhoneIcon class="h-4 w-4" />
</Button>
<Button v-else-if="title == 'Notes'" variant="solid" @click="showNote">
<FeatherIcon name="plus" class="h-4 w-4" />
</Button>
</div>
<div v-if="activities.length" class="flex-1 overflow-y-auto">
<div v-if="activities?.length" class="flex-1 overflow-y-auto">
<div v-if="title == 'Notes'" class="grid grid-cols-3 gap-4 px-10 py-5 pt-0">
<div
v-for="note in activities"
class="group flex flex-col justify-between gap-2 px-4 py-3 border rounded-lg h-48 shadow-sm hover:bg-gray-50 cursor-pointer"
@click="emit('makeNote', note)"
class="group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-gray-50 px-4 py-3 hover:bg-gray-100"
@click="showNote(note)"
>
<div class="flex items-center justify-between">
<div class="text-lg font-medium truncate">
<div class="truncate text-lg font-medium">
{{ note.title }}
</div>
<Dropdown
@ -28,7 +30,7 @@
{
icon: 'trash-2',
label: 'Delete',
onClick: () => emit('deleteNote', note.name),
onClick: () => deleteNote(note.name),
},
]"
@click.stop
@ -37,7 +39,7 @@
<Button
icon="more-horizontal"
variant="ghosted"
class="hover:bg-white !h-6 !w-6"
class="!h-6 !w-6 hover:bg-gray-100"
/>
</Dropdown>
</div>
@ -48,7 +50,7 @@
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden"
/>
<div class="flex items-center justify-between mt-1 gap-2">
<div class="mt-1 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar :user="note.owner" size="xs" />
<div class="text-sm text-gray-800">
@ -65,24 +67,24 @@
</div>
<div v-else-if="title == 'Calls'">
<div v-for="(call, i) in activities">
<div class="grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-5">
<div class="grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-10">
<div
class="relative flex justify-center after:absolute after:border-l after:border-gray-300 after:top-0 after:left-[50%] after:-z-10"
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-gray-200"
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
>
<div
class="flex items-center justify-center rounded-full outline outline-4 outline-white w-6 h-6 bg-gray-200 mt-[15px] z-10"
class="z-10 mt-[15px] flex h-7 w-7 items-center justify-center rounded-full bg-gray-100"
>
<FeatherIcon
:name="
call.type == 'Incoming' ? 'phone-incoming' : 'phone-outgoing'
<component
:is="
call.type == 'Incoming' ? InboundCallIcon : OutboundCallIcon
"
class="w-3.5 h-3.5 text-gray-600"
class="text-gray-800"
/>
</div>
</div>
<div
class="flex flex-col gap-3 bg-gray-50 rounded-md p-4 mb-3 max-w-[70%]"
class="mb-3 flex max-w-[70%] flex-col gap-3 rounded-md bg-gray-50 p-4"
>
<div class="flex items-center justify-between">
<div>
@ -90,7 +92,7 @@
</div>
<div>
<Tooltip
class="text-gray-600 text-sm"
class="text-sm text-gray-600"
:text="dateFormat(call.creation, dateTooltipFormat)"
>
{{ timeAgo(call.creation) }}
@ -99,17 +101,17 @@
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<DurationIcon class="w-4 h-4 text-gray-600" />
<DurationIcon class="h-4 w-4 text-gray-600" />
<div class="text-sm text-gray-600">Duration</div>
<div class="text-sm">
{{ call.duration }}
</div>
</div>
<div
class="flex items-center gap-1 cursor-pointer select-none"
class="flex cursor-pointer select-none items-center gap-1"
@click="call.show_recording = !call.show_recording"
>
<PlayIcon class="w-4 h-4 text-gray-600" />
<PlayIcon class="h-4 w-4 text-gray-600" />
<div class="text-sm text-gray-600">
{{
call.show_recording ? 'Hide recording' : 'Listen to call'
@ -119,7 +121,7 @@
</div>
<div
v-if="call.show_recording"
class="flex items-center justify-between border rounded"
class="flex items-center justify-between rounded border"
>
<audio
class="audio-control"
@ -134,7 +136,7 @@
:label="call.caller.label"
size="xl"
/>
<div class="flex flex-col gap-1 ml-1">
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ call.caller.label }}
</div>
@ -144,14 +146,14 @@
</div>
<FeatherIcon
name="arrow-right"
class="w-5 h-5 text-gray-600 mx-2"
class="mx-2 h-5 w-5 text-gray-600"
/>
<Avatar
:image="call.receiver.image"
:label="call.receiver.label"
size="xl"
/>
<div class="flex flex-col gap-1 ml-1">
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ call.receiver.label }}
</div>
@ -168,33 +170,48 @@
<div v-else v-for="(activity, i) in activities">
<div class="grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-10">
<div
class="relative flex justify-center after:absolute after:border-l after:border-gray-200 after:top-0 after:left-[50%] after:-z-10"
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-gray-200"
:class="[
i != activities.length - 1 ? 'before:h-full' : 'before:h-4',
activity.other_versions
? 'after:translate-y-[calc(-50% - 4px)] after:absolute after:bottom-9 after:left-[50%] after:top-0 after:-z-10 after:w-8 after:rounded-bl-xl after:border-b after:border-l after:border-gray-200'
: '',
]"
>
<div
class="flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 z-10"
class="z-10 flex h-7 w-7 items-center justify-center rounded-full bg-gray-100"
:class="{
'mt-[15px]': [
'mt-3': [
'communication',
'incoming_call',
'outgoing_call',
].includes(activity.activity_type),
'bg-white': ['added', 'removed', 'changed'].includes(
activity.activity_type
),
}"
>
<FeatherIcon :name="activity.icon" class="w-4 h-4 text-gray-800" />
<component
:is="activity.icon"
:class="
['added', 'removed', 'changed'].includes(activity.activity_type)
? 'text-gray-600'
: 'text-gray-800'
"
/>
</div>
</div>
<div v-if="activity.activity_type == 'communication'" class="pb-6">
<div
class="rounded-md p-3 text-base cursor-pointer bg-gray-50 leading-6 transition-all duration-300 ease-in-out"
class="cursor-pointer rounded-md bg-gray-50 p-3 text-base leading-6 transition-all duration-300 ease-in-out"
>
<div class="flex items-center justify-between gap-2 mb-3">
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar :user="activity.data.sender" size="md" />
<span>{{ activity.data.sender_full_name }}</span>
<span>&middot;</span>
<Tooltip
class="text-gray-600 text-sm"
class="text-sm text-gray-600"
:text="dateFormat(activity.creation, dateTooltipFormat)"
>
{{ timeAgo(activity.creation) }}
@ -216,7 +233,7 @@
activity.activity_type == 'incoming_call' ||
activity.activity_type == 'outgoing_call'
"
class="flex flex-col gap-3 bg-gray-50 rounded-md p-4 mb-3 max-w-[70%]"
class="mb-3 flex max-w-[70%] flex-col gap-3 rounded-md bg-gray-50 p-4"
>
<div class="flex items-center justify-between">
<div>
@ -224,7 +241,7 @@
</div>
<div>
<Tooltip
class="text-gray-600 text-sm"
class="text-sm text-gray-600"
:text="dateFormat(activity.creation, dateTooltipFormat)"
>
{{ timeAgo(activity.creation) }}
@ -233,17 +250,17 @@
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<DurationIcon class="w-4 h-4 text-gray-600" />
<DurationIcon class="h-4 w-4 text-gray-600" />
<div class="text-sm text-gray-600">Duration</div>
<div class="text-sm">
{{ activity.duration }}
</div>
</div>
<div
class="flex items-center gap-1 cursor-pointer select-none"
class="flex cursor-pointer select-none items-center gap-1"
@click="activity.show_recording = !activity.show_recording"
>
<PlayIcon class="w-4 h-4 text-gray-600" />
<PlayIcon class="h-4 w-4 text-gray-600" />
<div class="text-sm text-gray-600">
{{
activity.show_recording ? 'Hide recording' : 'Listen to call'
@ -253,7 +270,7 @@
</div>
<div
v-if="activity.show_recording"
class="flex items-center justify-between border rounded"
class="flex items-center justify-between rounded border"
>
<audio
class="audio-control"
@ -268,7 +285,7 @@
:label="activity.caller.label"
size="xl"
/>
<div class="flex flex-col gap-1 ml-1">
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ activity.caller.label }}
</div>
@ -278,14 +295,14 @@
</div>
<FeatherIcon
name="arrow-right"
class="w-5 h-5 text-gray-600 mx-2"
class="mx-2 h-5 w-5 text-gray-600"
/>
<Avatar
:image="activity.receiver.image"
:label="activity.receiver.label"
size="xl"
/>
<div class="flex flex-col gap-1 ml-1">
<div class="ml-1 flex flex-col gap-1">
<div class="text-base font-medium">
{{ activity.receiver.label }}
</div>
@ -296,70 +313,162 @@
</div>
</div>
</div>
<div v-else class="flex flex-col gap-3 pb-6">
<div
class="flex items-start justify-stretch gap-2 text-base leading-6"
>
<div v-else class="mb-4 flex flex-col gap-5 py-1.5">
<div class="flex items-start justify-stretch gap-2 text-base">
<div class="inline-flex flex-wrap gap-1 text-gray-600">
<span class="text-gray-900">{{ activity.owner_name }}</span>
<span class="font-medium text-gray-800">{{
activity.owner_name
}}</span>
<span v-if="activity.type">{{ activity.type }}</span>
<span
v-if="activity.data.field_label"
class="text-gray-900 truncate max-w-xs"
class="max-w-xs truncate font-medium text-gray-800"
>
{{ activity.data.field_label }}
</span>
<span v-if="activity.value">{{ activity.value }}</span>
<span
v-if="activity.data.old_value"
class="text-gray-900 truncate max-w-xs"
class="max-w-xs font-medium text-gray-800"
>
{{ activity.data.old_value }}
<div
class="flex items-center gap-1"
v-if="activity.options == 'User'"
>
<UserAvatar :user="activity.data.old_value" size="xs" />
{{ getUser(activity.data.old_value).full_name }}
</div>
<div class="truncate" v-else>
{{ activity.data.old_value }}
</div>
</span>
<span v-if="activity.to">to</span>
<span
v-if="activity.data.value"
class="text-gray-900 truncate max-w-xs"
class="max-w-xs font-medium text-gray-800"
>
{{ activity.data.value }}
<div
class="flex items-center gap-1"
v-if="activity.options == 'User'"
>
<UserAvatar :user="activity.data.value" size="xs" />
{{ getUser(activity.data.value).full_name }}
</div>
<div class="truncate" v-else>
{{ activity.data.value }}
</div>
</span>
</div>
<div class="ml-auto whitespace-nowrap">
<Tooltip
:text="dateFormat(activity.creation, dateTooltipFormat)"
class="text-sm text-gray-600 leading-6"
class="text-gray-600"
>
{{ timeAgo(activity.creation) }}
</Tooltip>
</div>
</div>
<div
v-if="activity.activity_type == 'comment'"
class="py-3 px-4 rounded-xl shadow-sm border max-w-[80%] text-base cursor-pointer leading-6 transition-all duration-300 ease-in-out"
v-html="activity.data"
/>
v-if="activity.other_versions && activity.show_others"
v-for="activity in activity.other_versions"
class="flex items-start justify-stretch gap-2 text-base"
>
<div class="flex items-start gap-1 text-gray-600">
<div class="flex flex-1 items-center gap-1">
<span
v-if="activity.data.field_label"
class="max-w-xs truncate text-gray-600"
>
{{ activity.data.field_label }}
</span>
<FeatherIcon
name="arrow-right"
class="mx-1 h-4 w-4 text-gray-600"
/>
</div>
<div class="flex flex-wrap items-center gap-1">
<span v-if="activity.type">{{ startCase(activity.type) }}</span>
<span
v-if="activity.data.old_value"
class="max-w-xs font-medium text-gray-800"
>
<div
class="flex items-center gap-1"
v-if="activity.options == 'User'"
>
<UserAvatar :user="activity.data.old_value" size="xs" />
{{ getUser(activity.data.old_value).full_name }}
</div>
<div class="truncate" v-else>
{{ activity.data.old_value }}
</div>
</span>
<span v-if="activity.to">to</span>
<span
v-if="activity.data.value"
class="max-w-xs font-medium text-gray-800"
>
<div
class="flex items-center gap-1"
v-if="activity.options == 'User'"
>
<UserAvatar :user="activity.data.value" size="xs" />
{{ getUser(activity.data.value).full_name }}
</div>
<div class="truncate" v-else>
{{ activity.data.value }}
</div>
</span>
</div>
</div>
<div class="ml-auto whitespace-nowrap">
<Tooltip
:text="dateFormat(activity.creation, dateTooltipFormat)"
class="text-gray-600"
>
{{ timeAgo(activity.creation) }}
</Tooltip>
</div>
</div>
<div v-if="activity.other_versions">
<Button
:label="
activity.show_others ? 'Hide all changes' : 'Show all changes'
"
variant="outline"
@click="activity.show_others = !activity.show_others"
>
<template #suffix>
<FeatherIcon
:name="activity.show_others ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="flex-1 flex flex-col gap-3 items-center justify-center font-medium text-xl text-gray-500"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
>
<component :is="emptyTextIcon" class="w-10 h-10" />
<component :is="emptyTextIcon" class="h-10 w-10" />
<span>{{ emptyText }}</span>
<Button
v-if="title == 'Calls'"
variant="solid"
label="Make a call"
@click="emit('makeCall')"
@click="makeCall(lead.data.mobile_no)"
/>
<Button
v-else-if="title == 'Notes'"
variant="solid"
label="Create note"
@click="emit('makeNote')"
@click="showNote"
/>
<Button
v-else-if="title == 'Emails'"
@ -370,9 +479,11 @@
</div>
<CommunicationArea
ref="emailBox"
v-if="['Emails', 'Activity'].includes(title) && lead"
v-if="['Emails', 'Activity'].includes(title)"
v-model="lead"
v-model:reload="reload_email"
/>
<NoteModal v-model="showNoteModal" :note="note" @updateNote="updateNote" />
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
@ -381,9 +492,23 @@ import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import PlayIcon from '@/components/Icons/PlayIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DotIcon from '@/components/Icons/DotIcon.vue'
import EmailAtIcon from '@/components/Icons/EmailAtIcon.vue'
import InboundCallIcon from '@/components/Icons/InboundCallIcon.vue'
import OutboundCallIcon from '@/components/Icons/OutboundCallIcon.vue'
import CommunicationArea from '@/components/CommunicationArea.vue'
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
import NoteModal from '@/components/NoteModal.vue'
import {
timeAgo,
dateFormat,
dateTooltipFormat,
secondsToDuration,
startCase,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import {
Button,
FeatherIcon,
@ -391,65 +516,166 @@ import {
Dropdown,
TextEditor,
Avatar,
createResource,
createListResource,
call,
} from 'frappe-ui'
import { computed, h, defineModel } from 'vue'
import { ref, computed, h, defineModel, markRaw, watch } from 'vue'
const { getUser } = usersStore()
const { getContact } = contactsStore()
const props = defineProps({
title: {
type: String,
default: 'Activity',
},
activities: {
type: Array,
default: [],
},
})
const lead = defineModel()
const reload = defineModel('reload')
const emit = defineEmits(['makeCall', 'makeNote', 'deleteNote'])
const reload_email = ref(false)
const versions = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_activities',
params: { name: lead.value.data.name },
cache: ['activity', lead.value.data.name],
auto: true,
})
const calls = createListResource({
type: 'list',
doctype: 'CRM Call Log',
cache: ['Call Logs', lead.value.data.name],
fields: [
'name',
'caller',
'receiver',
'from',
'to',
'duration',
'start_time',
'end_time',
'status',
'type',
'recording_url',
'creation',
'note',
],
filters: { lead: lead.value.data.name },
orderBy: 'creation desc',
pageLength: 999,
auto: true,
transform: (docs) => {
docs.forEach((doc) => {
doc.show_recording = false
doc.activity_type =
doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call'
doc.duration = secondsToDuration(doc.duration)
if (doc.type === 'Incoming') {
doc.caller = {
label: getContact(doc.from)?.full_name || 'Unknown',
image: getContact(doc.from)?.image,
}
doc.receiver = {
label: getUser(doc.receiver).full_name,
image: getUser(doc.receiver).user_image,
}
} else {
doc.caller = {
label: getUser(doc.caller).full_name,
image: getUser(doc.caller).user_image,
}
doc.receiver = {
label: getContact(doc.to)?.full_name || 'Unknown',
image: getContact(doc.to)?.image,
}
}
})
return docs
},
})
const notes = createListResource({
type: 'list',
doctype: 'CRM Note',
cache: ['Notes', lead.value.data.name],
fields: ['name', 'title', 'content', 'owner', 'modified'],
filters: { lead: lead.value.data.name },
orderBy: 'modified desc',
pageLength: 999,
auto: true,
})
function all_activities() {
if (!versions.data) return []
if (!calls.data) return versions.data
return [...versions.data, ...calls.data].sort(
(a, b) => new Date(b.creation) - new Date(a.creation)
)
}
const activities = computed(() => {
if (props.title == 'Calls') {
props.activities.forEach((activity) => {
activity.show_recording = false
})
return props.activities
let activities = []
if (props.title == 'Activity') {
activities = all_activities()
} else if (props.title == 'Emails') {
activities = versions.data.filter(
(activity) => activity.activity_type === 'communication'
)
} else if (props.title == 'Calls') {
return calls.data
} else if (props.title == 'Notes') {
return notes.data
}
props.activities.forEach((activity) => {
activity.icon = timelineIcon(activity.activity_type)
activities.forEach((activity) => {
activity.icon = timelineIcon(activity.activity_type, activity.is_lead)
if (
activity.activity_type == 'incoming_call' ||
activity.activity_type == 'outgoing_call'
activity.activity_type == 'outgoing_call' ||
activity.activity_type == 'communication'
)
return
activity.owner_name = getUser(activity.owner).full_name
activity.type = ''
activity.value = ''
activity.to = ''
update_activities_details(activity)
if (activity.activity_type == 'creation') {
activity.type = activity.data
} else if (activity.activity_type == 'comment') {
activity.type = 'added a comment'
} else if (activity.activity_type == 'added') {
activity.type = 'added'
activity.value = 'value as'
} else if (activity.activity_type == 'removed') {
activity.type = 'removed'
activity.value = 'value'
} else if (activity.activity_type == 'changed') {
activity.type = 'changed'
activity.value = 'value from'
activity.to = 'to'
if (activity.other_versions) {
activity.show_others = false
activity.other_versions.forEach((other_version) => {
update_activities_details(other_version)
})
}
})
return props.activities
return activities
})
function update_activities_details(activity) {
activity.owner_name = getUser(activity.owner).full_name
activity.type = ''
activity.value = ''
activity.to = ''
if (activity.activity_type == 'creation') {
activity.type = activity.data
} else if (activity.activity_type == 'deal') {
activity.type = 'converted the lead to this deal'
activity.data.field_label = ''
activity.data.value = ''
} else if (activity.activity_type == 'added') {
activity.type = 'added'
activity.value = 'as'
} else if (activity.activity_type == 'removed') {
activity.type = 'removed'
activity.value = 'value'
} else if (activity.activity_type == 'changed') {
activity.type = 'changed'
activity.value = 'from'
activity.to = 'to'
}
}
const emptyText = computed(() => {
let text = 'No emails communications'
if (props.title == 'Calls') {
@ -470,22 +696,85 @@ const emptyTextIcon = computed(() => {
return h(icon, { class: 'text-gray-500' })
})
function timelineIcon(activity_type) {
if (activity_type == 'creation') {
return 'plus'
} else if (activity_type == 'removed') {
return 'trash-2'
} else if (activity_type == 'communication') {
return 'at-sign'
} else if (activity_type == 'comment') {
return 'file-text'
} else if (activity_type == 'incoming_call') {
return 'phone-incoming'
} else if (activity_type == 'outgoing_call') {
return 'phone-outgoing'
function timelineIcon(activity_type, is_lead) {
let icon
switch (activity_type) {
case 'creation':
icon = is_lead ? LeadsIcon : DealsIcon
break
case 'deal':
icon = DealsIcon
break
case 'communication':
icon = EmailAtIcon
break
case 'incoming_call':
icon = InboundCallIcon
break
case 'outgoing_call':
icon = OutboundCallIcon
break
default:
icon = DotIcon
}
return 'edit'
return markRaw(icon)
}
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
function showNote(n) {
note.value = n || {
title: '',
content: '',
}
showNoteModal.value = true
}
async function deleteNote(name) {
await call('frappe.client.delete', {
doctype: 'CRM Note',
name,
})
notes.reload()
}
async function updateNote(note) {
if (note.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: note.name,
fieldname: note,
})
if (d.name) {
notes.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: note.title,
content: note.content,
lead: props.leadId,
},
})
if (d.name) {
notes.reload()
}
}
}
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
if (reload_value || reload_email_value) {
versions.reload()
reload.value = false
reload_email.value = false
}
})
</script>
<style scoped>

View File

@ -1,11 +1,15 @@
<template>
<div class="flex gap-3 pt-2 pb-6 px-10">
<UserAvatar :user="getUser().name" size="xl" />
<div class="flex gap-3 px-10 pb-6 pt-2">
<UserAvatar
:user="getUser().name"
size="xl"
:class="showCommunicationBox ? 'mt-3' : ''"
/>
<Button
ref="sendEmailRef"
variant="outline"
size="md"
class="h-8.5 w-full inline-flex justify-between"
class="inline-flex h-8.5 w-full justify-between"
@click="showCommunicationBox = true"
v-show="!showCommunicationBox"
>
@ -22,12 +26,6 @@
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
>
<div class="mb-4 flex items-center">
<UserAvatar :user="getUser().name" size="sm" />
<span class="ml-2 text-base font-medium text-gray-900">
{{ getUser().full_name }}
</span>
</div>
<EmailEditor
ref="newEmailEditor"
:value="newEmail"
@ -44,7 +42,7 @@
},
}"
:editable="showCommunicationBox"
v-model="modelValue.data"
v-model="lead.data"
placeholder="Add a reply..."
/>
</div>
@ -58,7 +56,8 @@ import { usersStore } from '@/stores/users'
import { call } from 'frappe-ui'
import { ref, watch, computed, defineModel } from 'vue'
const modelValue = defineModel()
const lead = defineModel()
const reload = defineModel('reload')
const { getUser } = usersStore()
@ -86,13 +85,13 @@ const onNewEmailChange = (value) => {
async function sendMail() {
await call('frappe.core.doctype.communication.email.make', {
recipients: modelValue.value.data.email,
recipients: lead.value.data.email,
cc: '',
bcc: '',
subject: 'Email from Agent',
content: newEmail.value,
doctype: 'CRM Lead',
name: modelValue.value.data.name,
name: lead.value.data.name,
send_email: 1,
sender: getUser().name,
sender_full_name: getUser()?.full_name || undefined,
@ -104,7 +103,7 @@ async function submitComment() {
showCommunicationBox.value = false
await sendMail()
newEmail.value = ''
modelValue.value.reload()
reload.value = true
}
defineExpose({ show: showCommunicationBox })

View File

@ -1,10 +1,16 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M9.23906 2.91049c.12084-.14098.35185-.05552.35185.13016v4.18662h5.47429c.1708 0 .263.20043.1518.33016L8.76094 15.0895c-.12084.141-.35185.0555-.35185-.1302v-4.1866H2.93484c-.17087 0-.26305-.2004-.15185-.3301l6.45607-7.53211Z"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.51246 2.15214C8.0454 1.5782 8.99073 2.10255 8.78598 2.85854L8.06251 5.52981L14.3198 6.41225C14.5042 6.43825 14.6589 6.56458 14.7212 6.74006C14.7835 6.91555 14.7431 7.11112 14.6164 7.24758L8.48756 13.8479C7.95462 14.4218 7.00929 13.8975 7.21404 13.1415L7.93751 10.4702L1.68019 9.58777C1.4958 9.56176 1.34113 9.43544 1.27883 9.25995C1.21652 9.08447 1.2569 8.8889 1.38361 8.75244L7.51246 2.15214ZM7.55592 3.57495L2.76791 8.73127L8.63801 9.5591C8.78003 9.57913 8.9066 9.65918 8.98557 9.7789C9.06454 9.89862 9.0883 10.0465 9.05081 10.1849L8.4441 12.4251L13.2321 7.26875L7.36201 6.44091C7.21999 6.42089 7.09342 6.34084 7.01445 6.22112C6.93548 6.1014 6.91172 5.95354 6.94922 5.8151L7.55592 3.57495Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="8" r="3.5" fill="currentColor" />
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.00915 1.67113C6.43756 0.996093 8.0523 0.824124 9.59083 1.18318L9.47719 1.67009L9.59083 1.18318C11.1294 1.54222 12.5012 2.41118 13.4833 3.64873L13.4833 3.64875C14.4653 4.8863 14.9999 6.41969 15 7.99959V7.99961V8.64961C15 9.29937 14.7419 9.92256 14.2824 10.382C13.8229 10.8415 13.1998 11.0996 12.55 11.0996C11.9002 11.0996 11.2771 10.8415 10.8176 10.382C10.6732 10.2376 10.5487 10.077 10.4458 9.90455C9.87852 10.6319 8.99387 11.0996 8.00001 11.0996C6.28792 11.0996 4.90001 9.71167 4.90001 7.99961C4.90001 6.28752 6.28792 4.8996 8.00001 4.8996C8.81015 4.8996 9.54773 5.21039 10.1 5.71924V5.3996C10.1 5.12346 10.3239 4.8996 10.6 4.8996C10.8761 4.8996 11.1 5.12346 11.1 5.3996V7.99961V8.64961C11.1 9.03414 11.2528 9.40298 11.5247 9.67491C11.7966 9.94684 12.1654 10.0996 12.55 10.0996C12.9345 10.0996 13.3034 9.94684 13.5753 9.67491C13.8472 9.40298 14 9.03414 14 8.64961V7.99964C14 7.99963 14 7.99962 14 7.99961C13.9999 6.64545 13.5417 5.33113 12.7 4.27036C11.8582 3.20959 10.6823 2.46476 9.36356 2.15701L9.477 1.67093L9.36356 2.15701C8.04482 1.84925 6.66077 1.99665 5.43641 2.57525L5.24134 2.16246L5.43641 2.57525C4.21207 3.15384 3.21944 4.12961 2.61996 5.34387C2.02048 6.55813 1.84939 7.93946 2.13451 9.26331C2.41962 10.5871 3.14418 11.7756 4.19038 12.6354C5.23657 13.4952 6.54286 13.9758 7.89687 13.9991C9.25083 14.0224 10.5729 13.587 11.648 12.7636C11.8673 12.5958 12.1811 12.6374 12.349 12.8566C12.5169 13.0759 12.4752 13.3897 12.256 13.5576C11.0017 14.5181 9.45929 15.0261 7.87968 14.999C6.30002 14.9718 4.77601 14.4111 3.55545 13.408L3.55543 13.408C2.33489 12.4048 1.48956 11.0183 1.15692 9.47385C0.824284 7.92937 1.02389 6.31782 1.72329 4.90118L2.16883 5.12114L1.72329 4.90117C2.42268 3.48454 3.58074 2.34615 5.00915 1.67113ZM10.1 7.99961C10.1 6.83981 9.15978 5.8996 8.00001 5.8996C6.84021 5.8996 5.90001 6.8398 5.90001 7.99961C5.90001 9.15939 6.84021 10.0996 8.00001 10.0996C9.15978 10.0996 10.1 9.15939 10.1 7.99961Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -6,7 +6,6 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="16" height="16" fill="white" />
<path
fill-rule="evenodd"
clip-rule="evenodd"

View File

@ -1,21 +1,16 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.6562 13.7812H13.5725H15.052C16.1315 13.7812 16.9335 12.7815 16.6993 11.7277L16.6179 11.3616C16.2748 9.81742 14.9051 8.71875 13.3233 8.71875H10.8629C10.4404 8.71875 10.033 8.79714 9.65626 8.94148C9.4976 9.00227 9.34438 9.07475 9.19775 9.158"
stroke="currentColor"
/>
<circle cx="6.46875" cy="6.1875" r="2.03125" stroke="currentColor" />
<circle cx="12.375" cy="4.78125" r="1.75" stroke="currentColor" />
<path
d="M2.0092 12.7555C2.31423 11.3828 3.53167 10.4062 4.93776 10.4062H7.99974C9.40583 10.4062 10.6233 11.3828 10.9283 12.7555L11.1256 13.6434C11.3338 14.5801 10.6209 15.4688 9.66133 15.4688H3.27617C2.31655 15.4688 1.60372 14.5801 1.81189 13.6434L2.0092 12.7555Z"
fill="white"
stroke="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9011 3.88117C11.9011 4.46845 11.4251 4.94454 10.8378 4.94454C10.2505 4.94454 9.77442 4.46845 9.77442 3.88117C9.77442 3.29389 10.2505 2.81781 10.8378 2.81781C11.4251 2.81781 11.9011 3.29389 11.9011 3.88117ZM12.9011 3.88117C12.9011 5.02074 11.9773 5.94454 10.8378 5.94454C9.69822 5.94454 8.77442 5.02074 8.77442 3.88117C8.77442 2.74161 9.69822 1.81781 10.8378 1.81781C11.9773 1.81781 12.9011 2.74161 12.9011 3.88117ZM9.45115 6.99206C9.00149 6.99206 8.56731 7.07554 8.16567 7.22941C7.9966 7.29419 7.83337 7.37141 7.67722 7.46007L8.17096 8.32968C8.28372 8.26566 8.40152 8.20994 8.52344 8.16322C8.81278 8.05237 9.12582 7.99206 9.45115 7.99206H11.7074C12.9237 7.99206 13.9768 8.83683 14.2407 10.0242L14.3153 10.3599C14.4606 11.0141 13.9628 11.6346 13.2927 11.6346H11.936H11.0957V12.6346H11.936H13.2927C14.6026 12.6346 15.5756 11.4216 15.2915 10.1429L15.2168 9.80723C14.8513 8.16236 13.3924 6.99206 11.7074 6.99206H9.45115ZM4.29314 9.53958C3.07685 9.53958 2.02374 10.3844 1.75989 11.5717L1.68529 11.9074C1.53992 12.5616 2.03772 13.1822 2.70787 13.1822H8.13467C8.80482 13.1822 9.30263 12.5616 9.15725 11.9074L9.08265 11.5717C8.8188 10.3844 7.76569 9.53958 6.5494 9.53958H4.29314ZM0.783705 11.3548C1.14923 9.70989 2.60815 8.53958 4.29314 8.53958H6.5494C8.23439 8.53958 9.69331 9.70989 10.0588 11.3548L10.1334 11.6905C10.4176 12.9692 9.44457 14.1822 8.13467 14.1822H2.70787C1.39797 14.1822 0.424947 12.9692 0.709104 11.6905L0.783705 11.3548ZM6.74274 5.17078C6.74274 5.9005 6.15118 6.49206 5.42145 6.49206C4.69173 6.49206 4.10017 5.9005 4.10017 5.17078C4.10017 4.44105 4.69173 3.84949 5.42145 3.84949C6.15118 3.84949 6.74274 4.44105 6.74274 5.17078ZM7.74274 5.17078C7.74274 6.45279 6.70346 7.49206 5.42145 7.49206C4.13944 7.49206 3.10017 6.45279 3.10017 5.17078C3.10017 3.88877 4.13944 2.84949 5.42145 2.84949C6.70346 2.84949 7.74274 3.88877 7.74274 5.17078Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -6,7 +6,6 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="16" height="16" fill="white" />
<path
fill-rule="evenodd"
clip-rule="evenodd"

View File

@ -36,8 +36,8 @@
</template>
</LayoutHeader>
<div v-if="deal.data" class="flex h-full overflow-hidden">
<TabGroup as="div" class="flex flex-col flex-1" @change="onTabChange">
<TabList class="flex items-center gap-6 border-b pl-5 relative">
<TabGroup as="div" class="flex flex-1 flex-col" @change="onTabChange">
<TabList class="relative flex items-center gap-6 border-b pl-5">
<Tab
ref="tabRef"
as="template"
@ -46,7 +46,7 @@
v-slot="{ selected }"
>
<button
class="flex items-center gap-2 py-2.5 -mb-[1px] text-base text-gray-600 border-b border-transparent hover:text-gray-900 hover:border-gray-400 transition-all duration-300 ease-in-out"
class="-mb-[1px] flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 transition-all duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
:class="{ 'text-gray-900': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
@ -55,40 +55,37 @@
</Tab>
<div
ref="indicator"
class="h-[1px] bg-gray-900 w-[82px] absolute -bottom-[1px]"
class="absolute -bottom-[1px] h-[1px] w-[82px] bg-gray-900"
:style="{ left: `${indicatorLeftValue}px` }"
/>
</TabList>
<TabPanels class="flex flex-1 overflow-hidden">
<TabPanel
class="flex-1 flex flex-col overflow-y-auto"
class="flex flex-1 flex-col overflow-y-auto"
v-for="tab in tabs"
:key="tab.label"
>
<Activities
:title="tab.activityTitle"
:activities="tab.content"
:title="tab.label"
v-model:reload="reload"
v-model="deal"
@makeCall="makeCall(deal.data.mobile_no)"
@makeNote="(e) => showNote(e)"
@deleteNote="(e) => deleteNote(e)"
/>
</TabPanel>
</TabPanels>
</TabGroup>
<div class="flex flex-col justify-between border-l w-[352px]">
<div class="flex w-[352px] flex-col justify-between border-l">
<div
class="flex items-center border-b px-5 py-2.5 h-[41px] font-semibold text-lg"
class="flex h-[41px] items-center border-b px-5 py-2.5 text-lg font-semibold"
>
About this deal
</div>
<FileUploader @success="changeDealImage" :validateFile="validateFile">
<template #default="{ openFileSelector, error }">
<div class="flex gap-5 items-center justify-start p-5 border-b">
<div class="relative w-[88px] h-[88px] group">
<div class="flex items-center justify-start gap-5 border-b p-5">
<div class="group relative h-[88px] w-[88px]">
<Avatar
size="3xl"
class="w-[88px] h-[88px]"
class="h-[88px] w-[88px]"
:label="deal.data.organization_name"
:image="deal.data.organization_logo"
/>
@ -113,19 +110,19 @@
class="!absolute bottom-0 left-0 right-0"
>
<div
class="absolute bottom-0 left-0 right-0 rounded-b-full z-1 h-11 flex items-center justify-center pt-3 bg-black bg-opacity-40 cursor-pointer opacity-0 group-hover:opacity-100 duration-300 ease-in-out"
class="z-1 absolute bottom-0 left-0 right-0 flex h-11 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(12px 0 0 0);
clip-path: inset(12px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 text-white cursor-pointer" />
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</Dropdown>
</div>
<div class="flex flex-col gap-2.5 truncate">
<Tooltip :text="deal.data.organization_name">
<div class="font-medium text-2xl truncate">
<div class="truncate text-2xl font-medium">
{{ deal.data.organization_name }}
</div>
</Tooltip>
@ -155,7 +152,7 @@
</div>
</template>
</FileUploader>
<div class="flex-1 flex flex-col justify-between overflow-hidden">
<div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections"
@ -165,7 +162,7 @@
>
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }">
<div
class="flex items-center gap-2 text-base font-semibold leading-5 pl-2 pr-3 cursor-pointer max-w-fit"
class="flex max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
@ -187,9 +184,9 @@
<div
v-for="field in section.fields"
:key="field.label"
class="flex items-center px-3 gap-2 text-base leading-5 first:mt-3"
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
>
<div class="text-gray-600 w-[106px]">
<div class="w-[106px] text-gray-600">
{{ field.label }}
</div>
<div class="flex-1">
@ -241,7 +238,7 @@
variant="ghost"
@click="togglePopover()"
:label="getUser(deal.data[field.name]).full_name"
class="!justify-start w-full"
class="w-full !justify-start"
>
<template #prefix>
<UserAvatar
@ -269,7 +266,7 @@
<template #default="{ open }">
<Button
:label="deal.data[field.name]"
class="justify-between w-full"
class="w-full justify-between"
>
<template #prefix>
<IndicatorIcon
@ -340,13 +337,11 @@
</div>
</div>
</div>
<NoteModal v-model="showNoteModal" :note="note" @updateNote="updateNote" />
</template>
<script setup>
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
@ -356,21 +351,18 @@ import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import NoteModal from '@/components/NoteModal.vue'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
import { TransitionPresets, useTransition } from '@vueuse/core'
import {
dealStatuses,
statusDropdownOptions,
openWebsite,
secondsToDuration,
createToast,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import {
createResource,
createListResource,
FeatherIcon,
FileUploader,
ErrorMessage,
@ -379,12 +371,11 @@ import {
Dropdown,
Tooltip,
Avatar,
call,
} from 'frappe-ui'
import { ref, computed } from 'vue'
const { getUser, users } = usersStore()
const { getContact, contacts } = contactsStore()
const { contacts } = contactsStore()
const props = defineProps({
dealId: {
@ -400,6 +391,8 @@ const deal = createResource({
auto: true,
})
const reload = ref(false)
function updateDeal(fieldname, value) {
createResource({
url: 'frappe.client.set_value',
@ -413,6 +406,7 @@ function updateDeal(fieldname, value) {
onSuccess: () => {
deal.reload()
contacts.reload()
reload.value = true
createToast({
title: 'Deal updated',
icon: 'check',
@ -439,49 +433,24 @@ const breadcrumbs = computed(() => {
return items
})
const tabs = computed(() => {
return [
{
label: 'Activity',
icon: ActivityIcon,
content: all_activities(),
activityTitle: 'Activity',
},
{
label: 'Emails',
icon: EmailIcon,
content: deal.data.activities.filter(
(activity) => activity.activity_type === 'communication'
),
activityTitle: 'Emails',
},
{
label: 'Calls',
icon: PhoneIcon,
content: calls.data,
activityTitle: 'Calls',
},
// {
// label: 'Tasks',
// icon: TaskIcon,
// activityTitle: 'Tasks',
// },
{
label: 'Notes',
icon: NoteIcon,
activityTitle: 'Notes',
content: notes.data,
},
]
})
function all_activities() {
if (!deal.data) return []
if (!calls.data) return deal.data.activities
return [...deal.data.activities, ...calls.data].sort(
(a, b) => new Date(b.creation) - new Date(a.creation)
)
}
const tabs = [
{
label: 'Activity',
icon: ActivityIcon,
},
{
label: 'Emails',
icon: EmailIcon,
},
{
label: 'Calls',
icon: PhoneIcon,
},
{
label: 'Notes',
icon: NoteIcon,
},
]
function changeDealImage(file) {
deal.data.organization_logo = file.file_url
@ -612,116 +581,6 @@ const activeAgents = computed(() => {
})
})
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
const notes = createListResource({
type: 'list',
doctype: 'CRM Note',
cache: ['Notes', props.dealId],
fields: ['name', 'title', 'content', 'owner', 'modified'],
filters: { lead: props.dealId },
orderBy: 'modified desc',
pageLength: 999,
auto: true,
})
function showNote(n) {
note.value = n || {
title: '',
content: '',
}
showNoteModal.value = true
}
async function deleteNote(name) {
await call('frappe.client.delete', {
doctype: 'CRM Note',
name,
})
notes.reload()
}
async function updateNote(note) {
if (note.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: note.name,
fieldname: note,
})
if (d.name) {
notes.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: note.title,
content: note.content,
lead: props.dealId,
},
})
if (d.name) {
notes.reload()
}
}
}
const calls = createListResource({
type: 'list',
doctype: 'CRM Call Log',
cache: ['Call Logs', props.dealId],
fields: [
'name',
'caller',
'receiver',
'from',
'to',
'duration',
'start_time',
'end_time',
'status',
'type',
'recording_url',
'creation',
'note',
],
filters: { lead: props.dealId },
orderBy: 'creation desc',
pageLength: 999,
auto: true,
transform: (docs) => {
docs.forEach((doc) => {
doc.activity_type =
doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call'
doc.duration = secondsToDuration(doc.duration)
if (doc.type === 'Incoming') {
doc.caller = {
label: getContact(doc.from)?.full_name || 'Unknown',
image: getContact(doc.from)?.image,
}
doc.receiver = {
label: getUser(doc.receiver).full_name,
image: getUser(doc.receiver).user_image,
}
} else {
doc.caller = {
label: getUser(doc.caller).full_name,
image: getUser(doc.caller).user_image,
}
doc.receiver = {
label: getContact(doc.to)?.full_name || 'Unknown',
image: getContact(doc.to)?.image,
}
}
})
return docs
},
})
function updateAssignedAgent(email) {
deal.data.lead_owner = email
updateDeal('lead_owner', email)

View File

@ -258,6 +258,8 @@ let newDeal = reactive({
email: '',
mobile_no: '',
lead_owner: getUser().email,
is_deal: 1,
created_as_deal: 1,
})
const createLead = createResource({
@ -284,9 +286,9 @@ function createNewDeal(close) {
},
onSuccess(data) {
router.push({
name: 'Lead',
name: 'Deal',
params: {
leadId: data.name,
dealId: data.name,
},
})
},

View File

@ -38,9 +38,9 @@
/>
</template>
</LayoutHeader>
<div v-if="lead.data" class="flex h-full overflow-hidden">
<TabGroup as="div" class="flex flex-col flex-1" @change="onTabChange">
<TabList class="flex items-center gap-6 border-b pl-5 relative">
<div v-if="lead?.data" class="flex h-full overflow-hidden">
<TabGroup as="div" class="flex flex-1 flex-col" @change="onTabChange">
<TabList class="relative flex items-center gap-6 border-b pl-5">
<Tab
ref="tabRef"
as="template"
@ -49,7 +49,7 @@
v-slot="{ selected }"
>
<button
class="flex items-center gap-2 py-2.5 -mb-[1px] text-base text-gray-600 border-b border-transparent hover:text-gray-900 hover:border-gray-400 transition-all duration-300 ease-in-out"
class="-mb-[1px] flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 transition-all duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
:class="{ 'text-gray-900': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
@ -58,40 +58,37 @@
</Tab>
<div
ref="indicator"
class="h-[1px] bg-gray-900 w-[82px] absolute -bottom-[1px]"
class="absolute -bottom-[1px] h-[1px] w-[82px] bg-gray-900"
:style="{ left: `${indicatorLeftValue}px` }"
/>
</TabList>
<TabPanels class="flex flex-1 overflow-hidden">
<TabPanel
class="flex-1 flex flex-col overflow-y-auto"
class="flex flex-1 flex-col overflow-y-auto"
v-for="tab in tabs"
:key="tab.label"
>
<Activities
:title="tab.activityTitle"
:activities="tab.content"
:title="tab.label"
v-model:reload="reload"
v-model="lead"
@makeCall="makeCall(lead.data.mobile_no)"
@makeNote="(e) => showNote(e)"
@deleteNote="(e) => deleteNote(e)"
/>
</TabPanel>
</TabPanels>
</TabGroup>
<div class="flex flex-col justify-between border-l w-[352px]">
<div class="flex w-[352px] flex-col justify-between border-l">
<div
class="flex items-center border-b px-5 py-2.5 h-[41px] font-semibold text-lg"
class="flex h-[41px] items-center border-b px-5 py-2.5 text-lg font-semibold"
>
About this lead
</div>
<FileUploader @success="changeLeadImage" :validateFile="validateFile">
<template #default="{ openFileSelector, error }">
<div class="flex gap-5 items-center justify-start p-5">
<div class="relative w-[88px] h-[88px] group">
<div class="flex items-center justify-start gap-5 p-5">
<div class="group relative h-[88px] w-[88px]">
<Avatar
size="3xl"
class="w-[88px] h-[88px]"
class="h-[88px] w-[88px]"
:label="lead.data.first_name"
:image="lead.data.image"
/>
@ -114,19 +111,19 @@
class="!absolute bottom-0 left-0 right-0"
>
<div
class="absolute bottom-0 left-0 right-0 rounded-b-full z-1 h-11 flex items-center justify-center pt-3 bg-black bg-opacity-40 cursor-pointer opacity-0 group-hover:opacity-100 duration-300 ease-in-out"
class="z-1 absolute bottom-0 left-0 right-0 flex h-11 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(12px 0 0 0);
clip-path: inset(12px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 text-white cursor-pointer" />
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</Dropdown>
</div>
<div class="flex flex-col gap-2.5 truncate">
<Tooltip :text="lead.data.lead_name">
<div class="font-medium text-2xl truncate">
<div class="truncate text-2xl font-medium">
{{ lead.data.lead_name }}
</div>
</Tooltip>
@ -156,7 +153,7 @@
</div>
</template>
</FileUploader>
<div class="flex-1 flex flex-col justify-between overflow-hidden">
<div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections"
@ -164,9 +161,9 @@
class="flex flex-col"
>
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }">
<div class="sticky bg-white top-0 p-3 border-t z-10">
<div class="sticky top-0 z-10 border-t bg-white p-3">
<div
class="flex items-center gap-2 text-base font-semibold leading-5 px-2 cursor-pointer max-w-fit"
class="flex max-w-fit cursor-pointer items-center gap-2 px-2 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
@ -185,13 +182,13 @@
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div v-if="opened" class="flex flex-col gap-1.5 p-3 pt-0">
<div v-if="opened" class="flex flex-col gap-1.5 px-3">
<div
v-for="field in section.fields"
:key="field.name"
class="flex items-center px-3 gap-2 text-base leading-5"
class="flex items-center gap-2 px-3 text-base leading-5 last:mb-3"
>
<div class="text-gray-600 w-[106px]">
<div class="w-[106px] text-gray-600">
{{ field.label }}
</div>
<div class="flex-1">
@ -243,7 +240,7 @@
variant="ghost"
@click="togglePopover()"
:label="getUser(lead.data[field.name]).full_name"
class="!justify-start w-full"
class="w-full !justify-start"
>
<template #prefix>
<UserAvatar
@ -271,7 +268,7 @@
<template #default="{ open }">
<Button
:label="lead.data[field.name]"
class="justify-between w-full"
class="w-full justify-between"
>
<template #prefix>
<IndicatorIcon
@ -312,7 +309,6 @@
</div>
</div>
</div>
<NoteModal v-model="showNoteModal" :note="note" @updateNote="updateNote" />
</template>
<script setup>
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -326,21 +322,18 @@ import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import NoteModal from '@/components/NoteModal.vue'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
import { TransitionPresets, useTransition } from '@vueuse/core'
import {
leadStatuses,
statusDropdownOptions,
openWebsite,
secondsToDuration,
createToast,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import {
createResource,
createListResource,
FileUploader,
ErrorMessage,
FeatherIcon,
@ -349,14 +342,13 @@ import {
Dropdown,
Tooltip,
Avatar,
call,
} from 'frappe-ui'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CameraIcon from '../components/Icons/CameraIcon.vue'
const { getUser, users } = usersStore()
const { getContact, contacts } = contactsStore()
const { contacts } = contactsStore()
const router = useRouter()
const props = defineProps({
@ -373,6 +365,8 @@ const lead = createResource({
auto: true,
})
const reload = ref(false)
function updateLead(fieldname, value) {
createResource({
url: 'frappe.client.set_value',
@ -384,8 +378,12 @@ function updateLead(fieldname, value) {
},
auto: true,
onSuccess: () => {
if (fieldname == 'is_deal') {
router.push({ name: 'Deal', params: { dealId: lead.data.name } })
}
lead.reload()
contacts.reload()
reload.value = true
createToast({
title: 'Lead updated',
icon: 'check',
@ -412,49 +410,24 @@ const breadcrumbs = computed(() => {
return items
})
const tabs = computed(() => {
return [
{
label: 'Activity',
icon: ActivityIcon,
content: all_activities(),
activityTitle: 'Activity',
},
{
label: 'Emails',
icon: EmailIcon,
content: lead.data.activities.filter(
(activity) => activity.activity_type === 'communication'
),
activityTitle: 'Emails',
},
{
label: 'Calls',
icon: PhoneIcon,
content: calls.data,
activityTitle: 'Calls',
},
// {
// label: 'Tasks',
// icon: TaskIcon,
// activityTitle: 'Tasks',
// },
{
label: 'Notes',
icon: NoteIcon,
activityTitle: 'Notes',
content: notes.data,
},
]
})
function all_activities() {
if (!lead.data) return []
if (!calls.data) return lead.data.activities
return [...lead.data.activities, ...calls.data].sort(
(a, b) => new Date(b.creation) - new Date(a.creation)
)
}
const tabs = [
{
label: 'Activity',
icon: ActivityIcon,
},
{
label: 'Emails',
icon: EmailIcon,
},
{
label: 'Calls',
icon: PhoneIcon,
},
{
label: 'Notes',
icon: NoteIcon,
},
]
function changeLeadImage(file) {
lead.data.image = file.file_url
@ -605,119 +578,8 @@ function convertToDeal() {
lead.data.status = 'Qualified'
lead.data.is_deal = 1
updateLead('is_deal', 1)
router.push({ name: 'Deal', params: { dealId: lead.data.name } })
}
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
const notes = createListResource({
type: 'list',
doctype: 'CRM Note',
cache: ['Notes', props.leadId],
fields: ['name', 'title', 'content', 'owner', 'modified'],
filters: { lead: props.leadId },
orderBy: 'modified desc',
pageLength: 999,
auto: true,
})
function showNote(n) {
note.value = n || {
title: '',
content: '',
}
showNoteModal.value = true
}
async function deleteNote(name) {
await call('frappe.client.delete', {
doctype: 'CRM Note',
name,
})
notes.reload()
}
async function updateNote(note) {
if (note.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: note.name,
fieldname: note,
})
if (d.name) {
notes.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: note.title,
content: note.content,
lead: props.leadId,
},
})
if (d.name) {
notes.reload()
}
}
}
const calls = createListResource({
type: 'list',
doctype: 'CRM Call Log',
cache: ['Call Logs', props.leadId],
fields: [
'name',
'caller',
'receiver',
'from',
'to',
'duration',
'start_time',
'end_time',
'status',
'type',
'recording_url',
'creation',
'note',
],
filters: { lead: props.leadId },
orderBy: 'creation desc',
pageLength: 999,
auto: true,
transform: (docs) => {
docs.forEach((doc) => {
doc.activity_type =
doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call'
doc.duration = secondsToDuration(doc.duration)
if (doc.type === 'Incoming') {
doc.caller = {
label: getContact(doc.from)?.full_name || 'Unknown',
image: getContact(doc.from)?.image,
}
doc.receiver = {
label: getUser(doc.receiver).full_name,
image: getUser(doc.receiver).user_image,
}
} else {
doc.caller = {
label: getUser(doc.caller).full_name,
image: getUser(doc.caller).user_image,
}
doc.receiver = {
label: getContact(doc.to)?.full_name || 'Unknown',
image: getContact(doc.to)?.image,
}
}
})
return docs
},
})
function updateAssignedAgent(email) {
lead.data.lead_owner = email
updateLead('lead_owner', email)

View File

@ -9,7 +9,6 @@
</Button>
</template>
</LayoutHeader>
<div class="border-b"></div>
<div v-if="notes.data?.length" class="grid grid-cols-4 gap-4 p-5 overflow-y-auto">
<div
v-for="note in notes.data"

View File

@ -124,3 +124,7 @@ export function formatNumberIntoCurrency(value) {
}
return ''
}
export function startCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}