811 lines
26 KiB
Vue

<template>
<ActivityHeader
v-model="tabIndex"
v-model:showWhatsappTemplates="showWhatsappTemplates"
v-model:showFilesUploader="showFilesUploader"
:tabs="tabs"
:title="title"
:doc="doc"
:emailBox="emailBox"
:whatsappBox="whatsappBox"
:modalRef="modalRef"
/>
<FadedScrollableDiv
:maskHeight="30"
class="flex flex-col flex-1 overflow-y-auto"
>
<div
v-if="all_activities?.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
>
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div
v-else-if="
activities?.length ||
(whatsappMessages.data?.length && title == 'WhatsApp')
"
class="activities"
>
<div v-if="title == 'WhatsApp' && whatsappMessages.data?.length">
<WhatsAppArea
class="px-3 sm:px-10"
v-model="whatsappMessages"
v-model:reply="replyMessage"
:messages="whatsappMessages.data"
/>
</div>
<div
v-else-if="title == 'Notes'"
class="grid grid-cols-1 gap-4 px-3 pb-3 sm:px-10 sm:pb-5 lg:grid-cols-2 xl:grid-cols-3"
>
<div v-for="note in activities" @click="modalRef.showNote(note)">
<NoteArea :note="note" v-model="all_activities" />
</div>
</div>
<div v-else-if="title == 'Comments'" class="pb-5">
<div v-for="(comment, i) in activities">
<div
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 sm:px-10"
>
<div
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
>
<div
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white"
>
<CommentIcon class="text-ink-gray-8" />
</div>
</div>
<CommentArea class="mb-4" :activity="comment" />
</div>
</div>
</div>
<div v-else-if="title == 'Tasks'" class="px-3 pb-3 sm:px-10 sm:pb-5">
<TaskArea :modalRef="modalRef" :tasks="activities" :doctype="doctype" />
</div>
<div v-else-if="title == 'Calls'" class="activity">
<div v-for="(call, i) in activities">
<div
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
>
<div
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
>
<div
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
>
<MissedCallIcon
v-if="call.status == 'No Answer'"
class="text-ink-red-4"
/>
<DeclinedCallIcon v-else-if="call.status == 'Busy'" />
<component
v-else
:is="
call.type == 'Incoming' ? InboundCallIcon : OutboundCallIcon
"
/>
</div>
</div>
<CallArea class="mb-4" :activity="call" />
</div>
</div>
</div>
<div
v-else-if="title == 'Attachments'"
class="px-3 pb-3 sm:px-10 sm:pb-5"
>
<AttachmentArea
:attachments="activities"
@reload="all_activities.reload() && scroll()"
/>
</div>
<div
v-else
v-for="(activity, i) in activities"
class="activity px-3 sm:px-10"
:class="
['Activity', 'Emails'].includes(title)
? 'grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 sm:gap-4'
: ''
"
>
<div
v-if="['Activity', 'Emails'].includes(title)"
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-outline-gray-modals"
:class="[i != activities.length - 1 ? 'before:h-full' : 'before:h-4']"
>
<div
class="z-10 flex h-7 w-7 items-center justify-center bg-surface-white"
:class="{
'mt-2.5': ['communication'].includes(activity.activity_type),
'bg-surface-white': ['added', 'removed', 'changed'].includes(
activity.activity_type,
),
'h-8': [
'comment',
'communication',
'incoming_call',
'outgoing_call',
].includes(activity.activity_type),
}"
>
<UserAvatar
v-if="activity.activity_type == 'communication'"
:user="activity.data.sender"
size="md"
/>
<MissedCallIcon
v-else-if="
['incoming_call', 'outgoing_call'].includes(
activity.activity_type,
) && activity.status == 'No Answer'
"
class="text-ink-red-4"
/>
<DeclinedCallIcon
v-else-if="
['incoming_call', 'outgoing_call'].includes(
activity.activity_type,
) && activity.status == 'Busy'
"
/>
<component
v-else
:is="activity.icon"
:class="
['added', 'removed', 'changed'].includes(activity.activity_type)
? 'text-ink-gray-4'
: 'text-ink-gray-8'
"
/>
</div>
</div>
<div
v-if="activity.activity_type == 'communication'"
class="pb-5 mt-px"
>
<EmailArea :activity="activity" :emailBox="emailBox" />
</div>
<div
class="mb-4"
:id="activity.name"
v-else-if="activity.activity_type == 'comment'"
>
<CommentArea :activity="activity" />
</div>
<div
class="mb-4 flex flex-col gap-2 py-1.5"
:id="activity.name"
v-else-if="activity.activity_type == 'attachment_log'"
>
<div class="flex items-center justify-stretch gap-2 text-base">
<div
class="inline-flex items-center flex-wrap gap-1.5 text-ink-gray-8 font-medium"
>
<span class="font-medium">{{ activity.owner_name }}</span>
<span class="text-ink-gray-5">{{ __(activity.data.type) }}</span>
<a
v-if="activity.data.file_url"
:href="activity.data.file_url"
target="_blank"
>
<span>{{ activity.data.file_name }}</span>
</a>
<span v-else>{{ activity.data.file_name }}</span>
<FeatherIcon
v-if="activity.data.is_private"
name="lock"
class="size-3"
/>
</div>
<div class="ml-auto whitespace-nowrap">
<Tooltip :text="formatDate(activity.creation)">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(activity.creation)) }}
</div>
</Tooltip>
</div>
</div>
</div>
<div
v-else-if="
activity.activity_type == 'incoming_call' ||
activity.activity_type == 'outgoing_call'
"
class="mb-4"
>
<CallArea :activity="activity" />
</div>
<div v-else class="mb-4 flex flex-col gap-2 py-1.5">
<div class="flex items-center justify-stretch gap-2 text-base">
<div
v-if="activity.other_versions"
class="inline-flex flex-wrap gap-1.5 text-ink-gray-8 font-medium"
>
<span>{{ activity.show_others ? __('Hide') : __('Show') }}</span>
<span> +{{ activity.other_versions.length + 1 }} </span>
<span>{{ __('changes from') }}</span>
<span>{{ activity.owner_name }}</span>
<Button
class="!size-4"
variant="ghost"
@click="activity.show_others = !activity.show_others"
>
<template #icon>
<SelectIcon />
</template>
</Button>
</div>
<div
v-else
class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5"
>
<span class="font-medium text-ink-gray-8">
{{ activity.owner_name }}
</span>
<span v-if="activity.type">{{ __(activity.type) }}</span>
<span
v-if="activity.data?.field_label"
class="max-w-xs truncate font-medium text-ink-gray-8"
>
{{ __(activity.data.field_label) }}
</span>
<span v-if="activity.value">{{ __(activity.value) }}</span>
<span
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<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-ink-gray-8"
>
<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="formatDate(activity.creation)">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(activity.creation)) }}
</div>
</Tooltip>
</div>
</div>
<div
v-if="activity.other_versions && activity.show_others"
class="flex flex-col gap-0.5"
>
<div
v-for="activity in [activity, ...activity.other_versions]"
class="flex items-start justify-stretch gap-2 py-1.5 text-base"
>
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
<span
v-if="activity.data?.field_label"
class="max-w-xs truncate text-ink-gray-5"
>
{{ __(activity.data.field_label) }}
</span>
<FeatherIcon
name="arrow-right"
class="mx-1 h-4 w-4 text-ink-gray-5"
/>
<span v-if="activity.type">
{{ startCase(__(activity.type)) }}
</span>
<span
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<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-ink-gray-8"
>
<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="formatDate(activity.creation)">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(activity.creation)) }}
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
<DataFields
:doctype="doctype"
:docname="doc.data.name"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
<div
v-else
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
>
<component :is="emptyTextIcon" class="h-10 w-10" />
<span>{{ __(emptyText) }}</span>
<MultiActionButton v-if="title == 'Calls'" :options="callActions" />
<Button
v-else-if="title == 'Notes'"
:label="__('Create Note')"
@click="modalRef.showNote()"
/>
<Button
v-else-if="title == 'Emails'"
:label="__('New Email')"
@click="emailBox.show = true"
/>
<Button
v-else-if="title == 'Comments'"
:label="__('New Comment')"
@click="emailBox.showComment = true"
/>
<Button
v-else-if="title == 'Tasks'"
:label="__('Create Task')"
@click="modalRef.showTask()"
/>
<Button
v-else-if="title == 'Attachments'"
:label="__('Upload Attachment')"
@click="showFilesUploader = true"
/>
</div>
</FadedScrollableDiv>
<div>
<CommunicationArea
ref="emailBox"
v-if="['Emails', 'Comments', 'Activity'].includes(title)"
v-model="doc"
v-model:reload="reload_email"
:doctype="doctype"
@scroll="scroll"
/>
<WhatsAppBox
ref="whatsappBox"
v-if="title == 'WhatsApp'"
v-model="doc"
v-model:reply="replyMessage"
v-model:whatsapp="whatsappMessages"
:doctype="doctype"
@scroll="scroll"
/>
</div>
<WhatsappTemplateSelectorModal
v-if="whatsappEnabled"
v-model="showWhatsappTemplates"
:doctype="doctype"
@send="(t) => sendTemplate(t)"
/>
<AllModals
ref="modalRef"
v-model="all_activities"
:doctype="doctype"
:doc="doc"
/>
<FilesUploader
v-if="doc.data?.name"
v-model="showFilesUploader"
:doctype="doctype"
:docname="doc.data.name"
@after="
() => {
all_activities.reload()
changeTabTo('attachments')
}
"
/>
</template>
<script setup>
import ActivityHeader from '@/components/Activities/ActivityHeader.vue'
import EmailArea from '@/components/Activities/EmailArea.vue'
import CommentArea from '@/components/Activities/CommentArea.vue'
import CallArea from '@/components/Activities/CallArea.vue'
import NoteArea from '@/components/Activities/NoteArea.vue'
import TaskArea from '@/components/Activities/TaskArea.vue'
import AttachmentArea from '@/components/Activities/AttachmentArea.vue'
import DataFields from '@/components/Activities/DataFields.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import MultiActionButton from '@/components/MultiActionButton.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DotIcon from '@/components/Icons/DotIcon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import SelectIcon from '@/components/Icons/SelectIcon.vue'
import MissedCallIcon from '@/components/Icons/MissedCallIcon.vue'
import DeclinedCallIcon from '@/components/Icons/DeclinedCallIcon.vue'
import InboundCallIcon from '@/components/Icons/InboundCallIcon.vue'
import OutboundCallIcon from '@/components/Icons/OutboundCallIcon.vue'
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import CommunicationArea from '@/components/CommunicationArea.vue'
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
import AllModals from '@/components/Activities/AllModals.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import { timeAgo, formatDate, startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { capture } from '@/telemetry'
import { Button, Tooltip, createResource } from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
import {
ref,
computed,
h,
markRaw,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from 'vue'
import { useRoute } from 'vue-router'
const { makeCall, $socket } = globalStore()
const { getUser } = usersStore()
const props = defineProps({
doctype: {
type: String,
default: 'CRM Lead',
},
tabs: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['afterSave'])
const route = useRoute()
const doc = defineModel()
const reload = defineModel('reload')
const tabIndex = defineModel('tabIndex')
const reload_email = ref(false)
const modalRef = ref(null)
const showFilesUploader = ref(false)
const title = computed(() => props.tabs?.[tabIndex.value]?.name || 'Activity')
const changeTabTo = (tabName) => {
const tabNames = props.tabs?.map((tab) => tab.name?.toLowerCase())
const index = tabNames?.indexOf(tabName)
if (index == -1) return
tabIndex.value = index
}
const all_activities = createResource({
url: 'crm.api.activities.get_activities',
params: { name: doc.value.data.name },
cache: ['activity', doc.value.data.name],
auto: true,
transform: ([versions, calls, notes, tasks, attachments]) => {
return { versions, calls, notes, tasks, attachments }
},
})
const showWhatsappTemplates = ref(false)
const whatsappMessages = createResource({
url: 'crm.api.whatsapp.get_whatsapp_messages',
cache: ['whatsapp_messages', doc.value.data.name],
params: {
reference_doctype: props.doctype,
reference_name: doc.value.data.name,
},
auto: true,
transform: (data) => sortByCreation(data),
onSuccess: () => nextTick(() => scroll()),
})
onBeforeUnmount(() => {
$socket.off('whatsapp_message')
})
onMounted(() => {
$socket.on('whatsapp_message', (data) => {
if (
data.reference_doctype === props.doctype &&
data.reference_name === doc.value.data.name
) {
whatsappMessages.reload()
}
})
nextTick(() => {
const hash = route.hash.slice(1) || null
let tabNames = props.tabs?.map((tab) => tab.name)
if (!tabNames?.includes(hash)) {
scroll(hash)
}
})
})
function sendTemplate(template) {
showWhatsappTemplates.value = false
capture('send_whatsapp_template', { doctype: props.doctype })
createResource({
url: 'crm.api.whatsapp.send_whatsapp_template',
params: {
reference_doctype: props.doctype,
reference_name: doc.value.data.name,
to: doc.value.data.mobile_no,
template,
},
auto: true,
})
}
const replyMessage = ref({})
function get_activities() {
if (!all_activities.data?.versions) return []
if (!all_activities.data?.calls.length)
return all_activities.data.versions || []
return [...all_activities.data.versions, ...all_activities.data.calls]
}
const activities = computed(() => {
let _activities = []
if (title.value == 'Activity') {
_activities = get_activities()
} else if (title.value == 'Emails') {
if (!all_activities.data?.versions) return []
_activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'communication',
)
} else if (title.value == 'Comments') {
if (!all_activities.data?.versions) return []
_activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'comment',
)
} else if (title.value == 'Calls') {
if (!all_activities.data?.calls) return []
return sortByCreation(all_activities.data.calls)
} else if (title.value == 'Tasks') {
if (!all_activities.data?.tasks) return []
return sortByModified(all_activities.data.tasks)
} else if (title.value == 'Notes') {
if (!all_activities.data?.notes) return []
return sortByModified(all_activities.data.notes)
} else if (title.value == 'Attachments') {
if (!all_activities.data?.attachments) return []
return sortByModified(all_activities.data.attachments)
}
_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 == 'communication'
)
return
update_activities_details(activity)
if (activity.other_versions) {
activity.show_others = false
activity.other_versions.forEach((other_version) => {
update_activities_details(other_version)
})
}
})
return sortByCreation(_activities)
})
function sortByCreation(list) {
return list.sort((a, b) => new Date(a.creation) - new Date(b.creation))
}
function sortByModified(list) {
return list.sort((b, a) => new Date(a.modified) - new Date(b.modified))
}
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 == '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 Activities'
if (title.value == 'Emails') {
text = 'No Email Communications'
} else if (title.value == 'Comments') {
text = 'No Comments'
} else if (title.value == 'Data') {
text = 'No Data'
} else if (title.value == 'Calls') {
text = 'No Call Logs'
} else if (title.value == 'Notes') {
text = 'No Notes'
} else if (title.value == 'Tasks') {
text = 'No Tasks'
} else if (title.value == 'Attachments') {
text = 'No Attachments'
} else if (title.value == 'WhatsApp') {
text = 'No WhatsApp Messages'
}
return text
})
const emptyTextIcon = computed(() => {
let icon = ActivityIcon
if (title.value == 'Emails') {
icon = Email2Icon
} else if (title.value == 'Comments') {
icon = CommentIcon
} else if (title.value == 'Data') {
icon = DetailsIcon
} else if (title.value == 'Calls') {
icon = PhoneIcon
} else if (title.value == 'Notes') {
icon = NoteIcon
} else if (title.value == 'Tasks') {
icon = TaskIcon
} else if (title.value == 'Attachments') {
icon = AttachmentIcon
} else if (title.value == 'WhatsApp') {
icon = WhatsAppIcon
}
return h(icon, { class: 'text-ink-gray-4' })
})
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 'comment':
icon = CommentIcon
break
case 'incoming_call':
icon = InboundCallIcon
break
case 'outgoing_call':
icon = OutboundCallIcon
break
case 'attachment_log':
icon = AttachmentIcon
break
default:
icon = DotIcon
}
return markRaw(icon)
}
const emailBox = ref(null)
const whatsappBox = ref(null)
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
if (reload_value || reload_email_value) {
all_activities.reload()
reload.value = false
reload_email.value = false
}
})
function scroll(hash) {
if (['tasks', 'notes'].includes(route.hash?.slice(1))) return
setTimeout(() => {
let el
if (!hash) {
let e = document.getElementsByClassName('activity')
el = e[e.length - 1]
} else {
el = document.getElementById(hash)
}
if (el && !useElementVisibility(el).value) {
el.scrollIntoView({ behavior: 'smooth' })
el.focus()
}
}, 500)
}
const callActions = computed(() => {
let actions = [
{
label: __('Create Call Log'),
onClick: () => modalRef.value.createCallLog(),
},
{
label: __('Make a Call'),
onClick: () => makeCall(doc.data.mobile_no),
condition: () => callEnabled.value,
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true,
)
})
defineExpose({ emailBox, all_activities, changeTabTo })
</script>