Merge pull request #44 from shariquerik/assignment

feat: Multiple assignment in Lead/Deal
This commit is contained in:
Shariq Ansari 2023-12-27 17:25:32 +05:30 committed by GitHub
commit 2a51773225
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 306 additions and 52 deletions

View File

@ -107,6 +107,7 @@ def get_list_data(doctype: str, filters: dict, order_by: str):
"value": "modified_by",
"options": "User",
},
{"label": "Assigned To", "type": "Text", "value": "_assign"},
{"label": "Owner", "type": "Link", "value": "owner", "options": "User"},
]

View File

@ -27,4 +27,5 @@ def get_deal(name):
)
deal["doctype_fields"] = get_doctype_fields("CRM Deal")
deal["doctype"] = "CRM Deal"
return deal

View File

@ -1,8 +1,10 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.desk.form.assign_to import add as assign
from frappe.model.document import Document
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
@ -15,6 +17,12 @@ class CRMDeal(Document):
def validate(self):
self.set_primary_contact()
self.set_primary_email_mobile_no()
if self.deal_owner and not self.is_new():
self.assign_agent(self.deal_owner)
def after_insert(self):
if self.deal_owner:
self.assign_agent(self.deal_owner)
def before_save(self):
self.apply_sla()
@ -53,6 +61,19 @@ class CRMDeal(Document):
self.email = ""
self.mobile_no = ""
def assign_agent(self, agent):
if not agent:
return
if self._assign:
assignees = json.loads(self._assign)
for assignee in assignees:
if agent == assignee:
# the agent is already set as an assignee
return
assign({"assign_to": [agent], "doctype": "CRM Deal", "name": self.name})
def set_sla(self):
"""
Find an SLA to apply to the deal.
@ -119,10 +140,9 @@ class CRMDeal(Document):
'width': '11rem',
},
{
'label': 'Deal Owner',
'type': 'Link',
'key': 'deal_owner',
'options': 'User',
'label': 'Assigned To',
'type': 'Text',
'key': '_assign',
'width': '10rem',
},
{
@ -145,6 +165,7 @@ class CRMDeal(Document):
"first_response_time",
"first_responded_on",
"modified",
"_assign",
]
return {'columns': columns, 'rows': rows}

View File

@ -15,4 +15,5 @@ def get_lead(name):
lead = lead.pop()
lead["doctype_fields"] = get_doctype_fields("CRM Lead")
lead["doctype"] = "CRM Lead"
return lead

View File

@ -1,8 +1,10 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.desk.form.assign_to import add as assign
from frappe.model.document import Document
from frappe.utils import has_gravatar, validate_email_address
@ -18,6 +20,12 @@ class CRMLead(Document):
self.set_lead_name()
self.set_title()
self.validate_email()
if self.lead_owner and not self.is_new():
self.assign_agent(self.lead_owner)
def after_insert(self):
if self.lead_owner:
self.assign_agent(self.lead_owner)
def before_save(self):
self.apply_sla()
@ -54,6 +62,19 @@ class CRMLead(Document):
if self.is_new() or not self.image:
self.image = has_gravatar(self.email)
def assign_agent(self, agent):
if not agent:
return
if self.get("_assign"):
assignees = json.loads(self._assign)
for assignee in assignees:
if agent == assignee:
# the agent is already set as an assignee
return
assign({"assign_to": [agent], "doctype": "CRM Lead", "name": self.name})
def create_contact(self, throw=True):
if not self.lead_name:
self.set_full_name()
@ -210,10 +231,9 @@ class CRMLead(Document):
'width': '11rem',
},
{
'label': 'Lead Owner',
'type': 'Link',
'key': 'lead_owner',
'options': 'User',
'label': 'Assigned To',
'type': 'Text',
'key': '_assign',
'width': '10rem',
},
{
@ -237,6 +257,7 @@ class CRMLead(Document):
"first_response_time",
"first_responded_on",
"modified",
"_assign",
"image",
]
return {'columns': columns, 'rows': rows}

View File

@ -17,7 +17,10 @@
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<div v-if="column.key === '_assign'" class="flex items-center">
<MultipleAvatar :avatars="item" />
</div>
<ListRowItem v-else :item="item">
<template #prefix>
<div v-if="column.key === 'status'">
<IndicatorIcon :class="item.color" />
@ -86,6 +89,7 @@
</template>
<script setup>
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import {

View File

@ -17,7 +17,10 @@
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<div v-if="column.key === '_assign'" class="flex items-center">
<MultipleAvatar :avatars="item" />
</div>
<ListRowItem v-else :item="item">
<template #prefix>
<div v-if="column.key === 'status'">
<IndicatorIcon :class="item.color" />
@ -97,6 +100,7 @@
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import {
Avatar,
ListView,

View File

@ -0,0 +1,150 @@
<template>
<Dialog
v-model="show"
:options="{
title: 'Assign To',
size: 'xl',
actions: [
{
label: 'Cancel',
variant: 'subtle',
onClick: () => {
assignees = oldAssignees
show = false
},
},
{
label: 'Update',
variant: 'solid',
onClick: () => updateAssignees(),
},
],
}"
>
<template #body-content>
<Link
class="form-control"
value=""
doctype="User"
@change="(option) => addValue(option) && ($refs.input.value = '')"
>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</Link>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Tooltip
:text="assignee.name"
v-for="assignee in assignees"
:key="assignee.name"
>
<Button
:label="getUser(assignee.name).full_name"
theme="gray"
variant="outline"
>
<template #prefix>
<UserAvatar :user="assignee.name" size="sm" />
</template>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(assignee.name)"
/>
</template>
</Button>
</Tooltip>
</div>
<ErrorMessage class="mt-2" v-if="error" :message="error" />
</template>
</Dialog>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { Dialog, Tooltip, FeatherIcon, call, ErrorMessage } from 'frappe-ui'
import { defineModel, ref } from 'vue'
import { watchOnce } from '@vueuse/core'
const props = defineProps({
doc: {
type: Object,
default: null,
},
})
const show = defineModel()
const assignees = defineModel('assignees')
const oldAssignees = ref([])
const error = ref('')
const { getUser } = usersStore()
const removeValue = (value) => {
assignees.value = assignees.value.filter(
(assignee) => assignee.name !== value
)
}
const addValue = (value) => {
error.value = ''
let obj = {
name: value,
image: getUser(value).user_image,
label: getUser(value).full_name,
}
if (!assignees.value.find((assignee) => assignee.name === value)) {
assignees.value.push(obj)
}
}
function updateAssignees() {
if (assignees.value.length === 0) {
error.value = 'Please select at least one assignee'
return
}
const removedAssignees = oldAssignees.value
.filter(
(assignee) => !assignees.value.find((a) => a.name === assignee.name)
)
.map((assignee) => assignee.name)
const addedAssignees = assignees.value
.filter(
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name)
)
.map((assignee) => assignee.name)
if (removedAssignees.length) {
for (let a of removedAssignees) {
call('frappe.desk.form.assign_to.remove', {
doctype: props.doc.doctype,
name: props.doc.name,
assign_to: a,
})
}
}
if (addedAssignees.length) {
call('frappe.desk.form.assign_to.add', {
doctype: props.doc.doctype,
name: props.doc.name,
assign_to: addedAssignees,
})
}
show.value = false
}
watchOnce(assignees, (value) => {
oldAssignees.value = [...value]
})
</script>

View File

@ -0,0 +1,32 @@
<template>
<div
v-if="avatars?.length"
class="mr-1.5 flex cursor-pointer flex-row-reverse items-center"
>
<Tooltip
:text="avatar.name"
v-for="avatar in reverseAvatars"
:key="avatar.name"
>
<Avatar
class="-mr-1.5 transform border-2 border-white transition hover:z-10 hover:scale-110"
shape="circle"
:image="avatar.image"
:label="avatar.label"
size="lg"
/>
</Tooltip>
</div>
</template>
<script setup>
import { Avatar, Tooltip } from 'frappe-ui'
import { computed } from 'vue'
const props = defineProps({
avatars: {
type: Array,
default: [],
},
})
const reverseAvatars = computed(() => props.avatars.reverse())
</script>

View File

@ -31,25 +31,10 @@
>
<Button icon="more-horizontal" />
</Dropdown>
<Link
class="form-control"
:value="getUser(deal.data.deal_owner).full_name"
doctype="User"
@change="(option) => updateField('deal_owner', option)"
placeholder="Deal Owner"
>
<template #prefix>
<UserAvatar class="mr-2" :user="deal.data.deal_owner" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</Link>
<MultipleAvatar
:avatars="deal.data._assignedTo"
@click="showAssignmentModal = true"
/>
<Dropdown :options="statusOptions('deal', updateField)">
<template #default="{ open }">
<Button
@ -296,6 +281,12 @@
afterInsert: (doc) => addContact(doc.name),
}"
/>
<AssignmentModal
v-if="deal.data"
:doc="deal.data"
v-model="showAssignmentModal"
v-model:assignees="deal.data._assignedTo"
/>
</template>
<script setup>
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -309,8 +300,9 @@ import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
@ -353,10 +345,19 @@ const deal = createResource({
params: { name: props.dealId },
cache: ['deal', props.dealId],
auto: true,
onSuccess: (data) => {
let assignees = JSON.parse(data._assign) || []
data._assignedTo = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
},
})
const reload = ref(false)
const showOrganizationModal = ref(false)
const showAssignmentModal = ref(false)
const _organization = ref({})
const organization = computed(() => {

View File

@ -33,7 +33,11 @@
<ViewSettings doctype="CRM Deal" v-model="deals" />
</div>
</div>
<DealsListView v-if="deals.data && rows.length" :rows="rows" :columns="deals.data.columns" />
<DealsListView
v-if="deals.data && rows.length"
:rows="rows"
:columns="deals.data.columns"
/>
<div v-else-if="deals.data" class="flex h-full items-center justify-center">
<div
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
@ -193,6 +197,13 @@ const rows = computed(() => {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
...(deal.deal_owner && getUser(deal.deal_owner)),
}
} else if (row == '_assign') {
let assignees = JSON.parse(deal._assign) || []
_rows[row] = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
} else if (['modified', 'creation'].includes(row)) {
_rows[row] = {
label: dateFormat(deal[row], dateTooltipFormat),

View File

@ -31,25 +31,10 @@
>
<Button icon="more-horizontal" />
</Dropdown>
<Link
class="form-control"
:value="getUser(lead.data.lead_owner).full_name"
doctype="User"
@change="(option) => updateField('lead_owner', option)"
placeholder="Lead Owner"
>
<template #prefix>
<UserAvatar class="mr-2" :user="lead.data.lead_owner" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</Link>
<MultipleAvatar
:avatars="lead.data._assignedTo"
@click="showAssignmentModal = true"
/>
<Dropdown :options="statusOptions('lead', updateField)">
<template #default="{ open }">
<Button
@ -205,6 +190,12 @@
}),
}"
/>
<AssignmentModal
v-if="lead.data"
:doc="lead.data"
v-model="showAssignmentModal"
v-model:assignees="lead.data._assignedTo"
/>
</template>
<script setup>
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -217,12 +208,12 @@ import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LinkIcon from '@/components/Icons/LinkIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import Link from '@/components/Controls/Link.vue'
import { openWebsite, createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
@ -261,10 +252,19 @@ const lead = createResource({
params: { name: props.leadId },
cache: ['lead', props.leadId],
auto: true,
onSuccess: (data) => {
let assignees = JSON.parse(data._assign) || []
data._assignedTo = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
},
})
const reload = ref(false)
const showOrganizationModal = ref(false)
const showAssignmentModal = ref(false)
const _organization = ref({})
const organization = computed(() => {

View File

@ -196,6 +196,13 @@ const rows = computed(() => {
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
...(lead.lead_owner && getUser(lead.lead_owner)),
}
} else if (row == '_assign') {
let assignees = JSON.parse(lead._assign) || []
_rows[row] = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,
label: getUser(user).full_name,
}))
} else if (['modified', 'creation'].includes(row)) {
_rows[row] = {
label: dateFormat(lead[row], dateTooltipFormat),