Merge pull request #44 from shariquerik/assignment
feat: Multiple assignment in Lead/Deal
This commit is contained in:
commit
2a51773225
@ -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"},
|
||||
]
|
||||
|
||||
|
||||
@ -27,4 +27,5 @@ def get_deal(name):
|
||||
)
|
||||
|
||||
deal["doctype_fields"] = get_doctype_fields("CRM Deal")
|
||||
deal["doctype"] = "CRM Deal"
|
||||
return deal
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
150
frontend/src/components/Modals/AssignmentModal.vue
Normal file
150
frontend/src/components/Modals/AssignmentModal.vue
Normal 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>
|
||||
32
frontend/src/components/MultipleAvatar.vue
Normal file
32
frontend/src/components/MultipleAvatar.vue
Normal 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>
|
||||
@ -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(() => {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user