Merge pull request #14 from shariquerik/refactor-activities
This commit is contained in:
commit
6be84ae867
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -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>·</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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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>
|
||||
|
||||
11
frontend/src/components/Icons/DotIcon.vue
Normal file
11
frontend/src/components/Icons/DotIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/EmailAtIcon.vue
Normal file
16
frontend/src/components/Icons/EmailAtIcon.vue
Normal 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>
|
||||
@ -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"
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -124,3 +124,7 @@ export function formatNumberIntoCurrency(value) {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function startCase(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user