1
0
forked from test/crm

feat: add assignment rule

(cherry picked from commit 0c5684905f44af211189bf674735b046858a5b86)

# Conflicts:
#	frontend/components.d.ts
#	yarn.lock
This commit is contained in:
Pratik Badhe 2025-09-01 06:22:56 +00:00 committed by Mergify
parent 49ed1ac174
commit d985a44291
23 changed files with 4747 additions and 30 deletions

View File

@ -0,0 +1,32 @@
import frappe
@frappe.whitelist()
def get_assignment_rules_list():
assignment_rules = []
for docname in frappe.get_all(
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
):
doc = frappe.get_value(
"Assignment Rule",
docname,
fieldname=[
"name",
"description",
"disabled",
"priority",
],
as_dict=True,
)
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
assignment_rules.append({**doc, "users_exists": users_exists})
return assignment_rules
@frappe.whitelist()
def duplicate_assignment_rule(docname, new_name):
doc = frappe.get_doc("Assignment Rule", docname)
doc.name = new_name
doc.assignment_rule_name = new_name
doc.insert()
return doc

View File

@ -25,6 +25,8 @@ def after_install(force=False):
add_standard_dropdown_items()
add_default_scripts()
create_default_manager_dashboard(force)
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()
frappe.db.commit()
@ -421,3 +423,80 @@ def add_default_scripts():
for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype)
create_forecasting_script()
def add_assignment_rule_property_setters():
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
default_fields = {
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"doc_type": "Assignment Rule",
"property_type": "Data",
"is_system_generated": 1,
}
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-assign_condition-depends_on",
"field_name": "assign_condition",
"property": "depends_on",
"value": "eval: !doc.assign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-assign_condition-depends_on"},
"value",
"eval: !doc.assign_condition_json",
)
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-unassign_condition-depends_on",
"field_name": "unassign_condition",
"property": "depends_on",
"value": "eval: !doc.unassign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-unassign_condition-depends_on"},
"value",
"eval: !doc.unassign_condition_json",
)
def create_assignment_rule_custom_fields():
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
click.secho("* Installing Custom Fields in Assignment Rule")
create_custom_fields(
{
"Assignment Rule": [
{
"description": "Autogenerated field by CRM App",
"fieldname": "assign_condition_json",
"fieldtype": "Code",
"label": "Assign Condition JSON",
"insert_after": "assign_condition",
"depends_on": "eval: doc.assign_condition_json",
},
{
"description": "Autogenerated field by CRM App",
"fieldname": "unassign_condition_json",
"fieldtype": "Code",
"label": "Unassign Condition JSON",
"insert_after": "unassign_condition",
"depends_on": "eval: doc.unassign_condition_json",
},
],
}
)
frappe.clear_cache(doctype="Assignment Rule")

View File

@ -15,4 +15,5 @@ crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities
crm.patches.v1_0.update_deal_status_type
crm.patches.v1_0.create_default_lost_reasons
crm.patches.v1_0.create_default_lost_reasons
crm.patches.v1_0.add_fields_in_assignment_rule

View File

@ -0,0 +1,9 @@
from crm.install import (
add_assignment_rule_property_setters,
create_assignment_rule_custom_fields,
)
def execute():
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()

3
frontend/.gitignore vendored
View File

@ -2,4 +2,5 @@ node_modules
.DS_Store
dist
dist-ssr
*.local
*.local
components.d.ts

View File

@ -0,0 +1,469 @@
<template>
<div
class="flex gap-2"
:class="[
{
'items-center': !props.isGroup,
},
]"
>
<div
class="flex gap-2 w-full"
:class="[
{
'items-center justify-between': !props.isGroup,
},
]"
>
<div :class="'text-end text-base text-gray-600'">
<div v-if="props.itemIndex == 0" class="min-w-[66px] text-start">
{{ __('Where') }}
</div>
<div v-else class="min-w-[66px] flex items-start">
<Button
variant="subtle"
class="w-max"
@click="toggleConjunction"
icon-right="refresh-cw"
:disabled="props.itemIndex > 2"
:label="conjunction"
/>
</div>
</div>
<div v-if="!props.isGroup" class="flex items-center gap-2 w-full">
<div id="fieldname" class="w-full">
<Autocomplete
:options="filterableFields.data"
v-model="props.condition[0]"
:placeholder="__('Field')"
@update:modelValue="updateField"
/>
</div>
<div id="operator">
<FormControl
v-if="!props.condition[0]"
disabled
type="text"
:placeholder="__('operator')"
class="w-[100px]"
/>
<FormControl
v-else
:disabled="!props.condition[0]"
type="select"
v-model="props.condition[1]"
@change="updateOperator"
:options="getOperators()"
class="w-max min-w-[100px]"
/>
</div>
<div id="value" class="w-full">
<FormControl
v-if="!props.condition[0]"
disabled
type="text"
:placeholder="__('condition')"
class="w-full"
/>
<component
v-else
:is="getValueControl()"
v-model="props.condition[2]"
@change="updateValue"
:placeholder="__('condition')"
/>
</div>
</div>
<CFConditions
v-if="props.isGroup && !(props.level == 2 || props.level == 4)"
:conditions="props.condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
/>
<Button
variant="outline"
v-if="props.isGroup && (props.level == 2 || props.level == 4)"
@click="show = true"
:label="__('Open nested conditions')"
/>
</div>
<div :class="'w-max'">
<Dropdown placement="right" :options="dropdownOptions">
<Button variant="ghost" icon="more-horizontal" />
</Dropdown>
</div>
</div>
<Dialog
v-model="show"
:options="{ size: '3xl', title: __('Nested conditions') }"
>
<template #body-content>
<CFConditions
:conditions="props.condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
/>
</template>
</Dialog>
</template>
<script setup>
import { TemplateOption } from '@/utils'
import {
Autocomplete,
Button,
DatePicker,
DateRangePicker,
DateTimePicker,
Dialog,
Dropdown,
FormControl,
Rating,
} from 'frappe-ui'
import { computed, defineEmits, h, ref } from 'vue'
import GroupIcon from '~icons/lucide/group'
import UnGroupIcon from '~icons/lucide/ungroup'
import CFConditions from './CFConditions.vue'
import { filterableFields } from './filterableFields'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const emit = defineEmits([
'remove',
'unGroupConditions',
'toggleConjunction',
'turnIntoGroup',
])
const props = defineProps({
condition: {
type: Array,
required: true,
},
isChild: {
type: Boolean,
default: false,
},
itemIndex: {
type: Number,
},
level: {
type: Number,
default: 0,
},
isGroup: {
type: Boolean,
default: false,
},
conjunction: {
type: String,
},
disableAddCondition: {
type: Boolean,
default: false,
},
})
const dropdownOptions = computed(() => {
const options = []
if (!props.isGroup && props.level < 4) {
options.push({
label: __('Turn into a group'),
icon: () => h(GroupIcon),
onClick: () => {
emit('turnIntoGroup')
},
})
}
if (props.isGroup) {
options.push({
label: __('Ungroup conditions'),
icon: () => h(UnGroupIcon),
onClick: () => {
emit('unGroupConditions')
},
})
}
options.push({
label: __('Remove'),
component: (props) =>
TemplateOption({
option: __('Remove'),
icon: 'trash-2',
active: props.active,
variant: 'danger',
onClick: () => {
emit('remove')
},
}),
condition: () => !props.isGroup,
})
options.push({
label: __('Remove group'),
component: (props) =>
TemplateOption({
option: __('Remove group'),
icon: 'trash-2',
active: props.active,
variant: 'danger',
onClick: () => {
emit('remove')
},
}),
condition: () => props.isGroup,
})
return options
})
const typeCheck = ['Check']
const typeLink = ['Link', 'Dynamic Link']
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const typeDate = ['Date', 'Datetime']
const typeRating = ['Rating']
function toggleConjunction() {
emit('toggleConjunction', props.conjunction)
}
const updateField = (field) => {
props.condition[0] = field?.fieldname
resetConditionValue()
}
const resetConditionValue = () => {
props.condition[2] = ''
}
function getValueControl() {
const [field, operator] = props.condition
if (!field) return null
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return null
const { fieldtype, options } = fieldData
if (operator == 'is') {
return h(FormControl, {
type: 'select',
options: [
{
label: 'Set',
value: 'set',
},
{
label: 'Not Set',
value: 'not set',
},
],
})
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
return h(FormControl, { type: 'text' })
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
const _options =
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
return h(FormControl, {
type: 'select',
options: _options.map((o) => ({
label: o,
value: o,
})),
})
} else if (typeLink.includes(fieldtype)) {
if (fieldtype == 'Dynamic Link') {
return h(FormControl, { type: 'text' })
}
return h(Link, {
class: 'form-control',
doctype: options,
value: props.condition[2],
})
} else if (typeNumber.includes(fieldtype)) {
return h(FormControl, { type: 'number' })
} else if (typeDate.includes(fieldtype) && operator == 'between') {
return h(DateRangePicker, { value: props.condition[2], iconLeft: '' })
} else if (typeDate.includes(fieldtype)) {
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
value: props.condition[2],
iconLeft: '',
})
} else if (typeRating.includes(fieldtype)) {
return h(Rating, {
modelValue: props.condition[2] || 0,
class: 'truncate',
'update:modelValue': (v) => updateValue(v),
})
} else {
return h(FormControl, { type: 'text' })
}
}
function updateValue(value) {
value = value.target ? value.target.value : value
if (props.condition[1] === 'between') {
props.condition[2] = [value.split(',')[0], value.split(',')[1]]
} else {
props.condition[2] = value + ''
}
}
function getSelectOptions(options) {
return options.split('\n')
}
function updateOperator(event) {
let oldOperatorValue = event.target._value
let newOperatorValue = event.target.value
props.condition[1] = event.target.value
if (!isSameTypeOperator(oldOperatorValue, newOperatorValue)) {
props.condition[2] = getDefaultValue(props.condition[0])
}
resetConditionValue()
}
function getOperators() {
let options = []
const field = props.condition[0]
if (!field) return options
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return options
const { fieldtype, fieldname } = fieldData
if (typeString.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (fieldname === '_assign') {
options = [
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'Is', value: 'is' },
]
}
if (typeNumber.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
{ label: '<', value: '<' },
{ label: '>', value: '>' },
{ label: '<=', value: '<=' },
{ label: '>=', value: '>=' },
],
)
}
if (typeSelect.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeLink.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeCheck.includes(fieldtype)) {
options.push(...[{ label: 'Equals', value: '==' }])
}
if (['Duration'].includes(fieldtype)) {
options.push(
...[
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeDate.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: 'Between', value: 'between' },
],
)
}
if (typeRating.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
],
)
}
const op = options.find((o) => o.value == props.condition[1])
props.condition[1] = op?.value || options[0].value
return options
}
function getDefaultValue(field) {
if (typeSelect.includes(field.fieldtype)) {
return getSelectOptions(field.options)[0]
}
if (typeCheck.includes(field.fieldtype)) {
return 'Yes'
}
if (typeDate.includes(field.fieldtype)) {
return null
}
if (typeRating.includes(field.fieldtype)) {
return 0
}
return ''
}
function isSameTypeOperator(oldOperator, newOperator) {
let textOperators = ['==', '!=', 'in', 'not in', '>', '<', '>=', '<=']
if (
textOperators.includes(oldOperator) &&
textOperators.includes(newOperator)
)
return true
return false
}
</script>

View File

@ -0,0 +1,142 @@
<template>
<div class="rounded-lg border border-gray-300 p-3 flex flex-col gap-4 w-full">
<template v-for="(condition, i) in props.conditions" :key="condition.field">
<CFCondition
v-if="Array.isArray(condition)"
:condition="condition"
:isChild="props.isChild"
:itemIndex="i"
@remove="removeCondition(condition)"
@unGroupConditions="unGroupConditions(condition)"
:level="props.level + 1"
@toggleConjunction="toggleConjunction"
:isGroup="isGroupCondition(condition[0])"
:conjunction="getConjunction()"
@turnIntoGroup="turnIntoGroup(condition)"
:disableAddCondition="props.disableAddCondition"
/>
</template>
<div v-if="props.isChild" class="flex">
<Dropdown v-slot="{ open }" :options="dropdownOptions">
<Button
:disabled="props.disableAddCondition"
:label="__('Add condition')"
icon-left="plus"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
/>
</Dropdown>
</div>
</div>
</template>
<script setup>
import { Button, Dropdown } from 'frappe-ui'
import { computed, watch } from 'vue'
import CFCondition from './CFCondition.vue'
import { filterableFields } from './filterableFields'
const props = defineProps({
conditions: {
type: Array,
required: true,
},
isChild: {
type: Boolean,
default: false,
},
level: {
type: Number,
default: 0,
},
disableAddCondition: {
type: Boolean,
default: false,
},
doctype: {
type: String,
required: true,
},
})
const getConjunction = () => {
let conjunction = 'and'
props.conditions.forEach((condition) => {
if (typeof condition == 'string') {
conjunction = condition
}
})
return conjunction
}
const turnIntoGroup = (condition) => {
props.conditions.splice(props.conditions.indexOf(condition), 1, [condition])
}
const isGroupCondition = (condition) => {
return Array.isArray(condition)
}
const dropdownOptions = computed(() => {
const options = [
{
label: __('Add condition'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, ['', '', ''])
},
},
]
if (props.level < 3) {
options.push({
label: __('Add condition group'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, [[]])
},
})
}
return options
})
function removeCondition(condition) {
const conditionIndex = props.conditions.indexOf(condition)
if (conditionIndex == 0) {
props.conditions.splice(conditionIndex, 2)
} else {
props.conditions.splice(conditionIndex - 1, 2)
}
}
function unGroupConditions(condition) {
const conjunction = getConjunction()
const newConditions = condition.map((c) => {
if (typeof c == 'string') {
return conjunction
}
return c
})
const index = props.conditions.indexOf(condition)
if (index !== -1) {
props.conditions.splice(index, 1, ...newConditions)
}
}
function toggleConjunction(conjunction) {
for (let i = 0; i < props.conditions.length; i++) {
if (typeof props.conditions[i] == 'string') {
props.conditions[i] = conjunction == 'and' ? 'or' : 'and'
}
}
}
watch(
() => props.doctype,
(doctype) => {
filterableFields.submit({
doctype,
})
},
{ immediate: true },
)
</script>

View File

@ -0,0 +1,17 @@
import { createResource } from 'frappe-ui'
export const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields',
transform: (data) => {
data = data
.filter((field) => !field.fieldname.startsWith('_'))
.map((field) => {
return {
label: field.label,
value: field.fieldname,
...field,
}
})
return data
},
})

View File

@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-settings2-icon lucide-settings-2"
>
<path d="M14 17H5" />
<path d="M19 7h-9" />
<circle cx="17" cy="17" r="3" />
<circle cx="7" cy="7" r="3" />
</svg>
</template>

View File

@ -0,0 +1,148 @@
<template>
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignee Rules')
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
'Define who receives the tickets and how theyre distributed among agents.',
)
}}
</span>
</div>
<div class="mt-8 flex items-center justify-between gap-2">
<div>
<div class="text-base font-medium text-ink-gray-8">
{{ __('Ticket Routing') }}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
{{
__('Choose how tickets are distributed among selected assignees.')
}}
</div>
</div>
<div>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
@click="togglePopover()"
>
<div>
{{
ticketRoutingOptions.find(
(option) => option.value == assignmentRuleData.rule,
)?.label
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
>
<div
v-for="option in ticketRoutingOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.rule = option.value
togglePopover()
}
"
>
<span>
{{ option.label }}
</span>
<FeatherIcon
v-if="assignmentRuleData.rule == option.value"
name="check"
class="size-4"
/>
</div>
</div>
</template>
</Popover>
</div>
</div>
<div class="mt-7 flex items-center justify-between gap-2">
<div>
<div class="text-base font-medium text-ink-gray-8">
{{ __('Assignees') }}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
{{ __('Choose who receives the tickets.') }}
</div>
</div>
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<div
v-for="user in users"
:key="user.name"
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
>
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
<div class="text-ink-gray-7">
{{ user.full_name }}
</div>
<Tooltip
v-if="user.email == assignmentRuleData.lastUser"
:text="__('Last user assigned by this rule')"
:hover-delay="0.35"
:placement="'top'"
>
<div
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
>
{{ __('Last') }}
</div>
</Tooltip>
<Button variant="ghost" icon="x" @click="removeAssignedUser(user)" />
</div>
</div>
<ErrorMessage :message="assignmentRuleErrors.users" />
</div>
</template>
<script setup>
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from 'frappe-ui'
import AssigneeSearch from './AssigneeSearch.vue'
import { computed, inject } from 'vue'
import { usersStore } from '@/stores/users'
const { getUser } = usersStore()
const assignmentRuleData = inject('assignmentRuleData')
const assignmentRuleErrors = inject('assignmentRuleErrors')
const validateAssignmentRule = inject('validateAssignmentRule')
const ticketRoutingOptions = [
{
label: 'Auto-rotate',
value: 'Round Robin',
},
{
label: 'Assign by workload',
value: 'Load Balancing',
},
]
const removeAssignedUser = (user) => {
assignmentRuleData.value.users = assignmentRuleData.value.users.filter(
(u) => u.user !== user.name,
)
validateAssignmentRule('users')
}
const users = computed(() => {
const _users = []
assignmentRuleData.value.users.forEach((user) => {
_users.push(getUser(user.user))
})
return _users
})
</script>

View File

@ -0,0 +1,166 @@
<template>
<Combobox :multiple="true">
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Button
variant="subtle"
icon-left="plus"
@click="togglePopover()"
:label="__('Add Assignee')"
/>
</template>
<template #body="{ togglePopover }">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="(e) => debouncedQuery(e.target.value)"
:value="query"
autocomplete="off"
:placeholder="__('Search')"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="query = ''"
>
<FeatherIcon name="x" class="w-4" />
</button>
</div>
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
<ComboboxOption
v-show="usersList.length > 0"
v-for="user in usersList"
:key="user.username"
:value="user"
as="template"
v-slot="{ active }"
@click="
(e) => {
e.stopPropagation()
addAssignee(user)
}
"
>
<li
class="flex items-center rounded p-1.5 w-full text-base"
:class="{ 'bg-gray-100': active }"
>
<div class="flex gap-2 items-center w-full select-none">
<Avatar
:shape="'circle'"
:image="user.user_image"
:label="user.full_name"
size="lg"
/>
<div class="flex flex-col gap-1">
<div class="font-semibold text-ink-gray-7">
{{ user.full_name }}
</div>
<div class="text-ink-gray-6">{{ user.email }}</div>
</div>
</div>
</li>
</ComboboxOption>
<li
v-if="usersList.length == 0"
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
>
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div class="border-t p-1.5 pb-0.5 *:w-full">
<Button
variant="ghost"
icon-left="plus"
class="w-full"
:label="__('Invite agent')"
@click="
() => {
inviteAgent()
togglePopover()
}
"
/>
</div>
</div>
</template>
</Popover>
</Combobox>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/vue'
import { useDebounceFn } from '@vueuse/core'
import { Avatar, Popover } from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import { usersStore } from '@/stores/users'
import { globalStore } from '@/stores/global'
import { activeSettingsPage } from '@/composables/settings'
const emit = defineEmits(['addAssignee'])
const query = ref('')
const { users } = usersStore()
const { $dialog } = globalStore()
const assignmentRuleData = inject('assignmentRuleData')
const debouncedQuery = useDebounceFn((val) => {
query.value = val
}, 300)
const usersList = computed(() => {
let filteredUsers =
users.data?.crmUsers?.filter((user) => user.name !== 'Administrator') || []
return filteredUsers
.filter(
(user) =>
user.name?.includes(query.value) ||
user.full_name?.includes(query.value),
)
.filter((user) => {
return !assignmentRuleData.value.users.some((u) => u.user === user.email)
})
})
const addAssignee = (user) => {
const userExists = assignmentRuleData.value.users.some(
(u) => u.user === user.user,
)
if (!userExists) {
assignmentRuleData.value.users.push({
full_name: user.full_name,
email: user.email,
user_image: user.user_image,
user: user.email,
})
emit('addAssignee', user)
}
}
const inviteAgent = () => {
$dialog({
title: __('Invite agent'),
message: __(
'You will be redirected to invite user page, unsaved changes will be lost.',
),
variant: 'solid',
actions: [
{
label: __('Go to invite page'),
variant: 'solid',
onClick: (close) => {
activeSettingsPage.value = 'Invite User'
close()
},
},
],
})
}
</script>

View File

@ -0,0 +1,204 @@
<template>
<div
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
>
<div class="w-full py-3 pl-2 col-span-7" @click="updateStep('view', data)">
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
<div
v-if="data.description && data.description.length > 0"
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{{ data.description }}
</div>
</div>
<div class="col-span-2">
<Select
class="w-max bg-transparent -ml-2 border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
:options="priorityOptions"
v-model="data.priority"
@update:modelValue="onPriorityChange"
/>
</div>
<div class="flex justify-between items-center w-full pr-2 col-span-2">
<Switch
size="sm"
:modelValue="!data.disabled"
@update:modelValue="onToggle"
/>
<Dropdown placement="right" :options="dropdownOptions">
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
</Dropdown>
</div>
</div>
<Dialog
:options="{ title: __('Duplicate Assignment Rule') }"
v-model="duplicateDialog.show"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
:label="__('New Assignment Rule Name')"
type="text"
v-model="duplicateDialog.name"
/>
</div>
</template>
<template #actions>
<div class="flex gap-2 justify-end">
<Button
variant="subtle"
:label="__('Close')"
@click="duplicateDialog.show = false"
/>
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Button,
createResource,
Dialog,
Dropdown,
FormControl,
Select,
Switch,
toast,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import { TemplateOption } from '@/utils'
const assignmentRulesList = inject('assignmentRulesList')
const updateStep = inject('updateStep')
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const priorityOptions = [
{ label: 'Low', value: '0' },
{ label: 'Low-Medium', value: '1' },
{ label: 'Medium', value: '2' },
{ label: 'Medium-High', value: '3' },
{ label: 'High', value: '4' },
]
const duplicateDialog = ref({
show: false,
name: '',
})
const isConfirmingDelete = ref(false)
const deleteAssignmentRule = () => {
createResource({
url: 'frappe.client.delete',
params: {
doctype: 'Assignment Rule',
name: props.data.name,
},
onSuccess: () => {
assignmentRulesList.reload()
isConfirmingDelete.value = false
toast.success(__('Assignment rule deleted'))
},
auto: true,
})
}
const dropdownOptions = [
{
label: __('Duplicate'),
onClick: () => {
duplicateDialog.value = {
show: true,
name: props.data.name + ' (Copy)',
}
},
icon: 'copy',
},
{
label: __('Delete'),
component: (props) =>
TemplateOption({
option: __('Delete'),
icon: 'trash-2',
active: props.active,
onClick: (e) => {
e.preventDefault()
e.stopImmediatePropagation()
isConfirmingDelete.value = true
},
}),
condition: () => !isConfirmingDelete.value,
},
{
label: __('Confirm Delete'),
component: (props) =>
TemplateOption({
option: __('Confirm Delete'),
icon: 'trash-2',
active: props.active,
theme: 'danger',
onClick: () => deleteAssignmentRule(),
}),
condition: () => isConfirmingDelete.value,
},
]
const duplicate = () => {
createResource({
url: 'crm.api.assignment_rule.duplicate_assignment_rule',
params: {
docname: props.data.name,
new_name: duplicateDialog.value.name,
},
onSuccess: (data) => {
assignmentRulesList.reload()
toast.success(__('Assignment rule duplicated'))
duplicateDialog.value.show = false
duplicateDialog.value.name = ''
updateStep('view', data)
},
auto: true,
})
}
const onPriorityChange = () => {
setAssignmentRuleValue('priority', props.data.priority)
}
const onToggle = () => {
if (!props.data.users_exists && props.data.disabled) {
toast.error(__('Cannot enable rule without adding users in it'))
return
}
setAssignmentRuleValue('disabled', !props.data.disabled, 'status')
}
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
createResource({
url: 'frappe.client.set_value',
params: {
doctype: 'Assignment Rule',
name: props.data.name,
fieldname: key,
value: value,
},
onSuccess: () => {
assignmentRulesList.reload()
toast.success(__('Assignment rule {0} updated', [fieldName || key]))
},
auto: true,
})
}
</script>

View File

@ -0,0 +1,19 @@
<template>
<AssignmentRules v-if="step.screen === 'list'" />
<AssignmentRuleView v-else-if="step.screen === 'view'" />
</template>
<script setup>
import { ref, provide } from 'vue'
import AssignmentRules from './AssignmentRules.vue'
import AssignmentRuleView from './AssignmentRuleView.vue'
const step = ref({ screen: 'list', data: null })
provide('step', step)
provide('updateStep', updateStep)
function updateStep(newStep, data) {
step.value = { screen: newStep, data }
}
</script>

View File

@ -0,0 +1,767 @@
<template>
<div
v-if="getAssignmentRuleData.loading"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
<div
v-if="!getAssignmentRuleData.loading"
class="sticky top-0 z-10 bg-white pb-6 px-10 py-8"
>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
<div class="flex items-center gap-4">
<div
class="flex items-center justify-between gap-2"
@click="assignmentRuleData.disabled = !assignmentRuleData.disabled"
>
<Switch size="sm" :model-value="!assignmentRuleData.disabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button
:disabled="Boolean(!isDirty && step.data)"
:label="__('Save')"
theme="gray"
variant="solid"
@click="saveAssignmentRule()"
:loading="isLoading || getAssignmentRuleData.loading"
/>
</div>
</div>
</div>
<div v-if="!getAssignmentRuleData.loading" class="overflow-y-auto px-10 pb-8">
<div class="grid grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
:placeholder="__('Name')"
:label="__('Name')"
v-model="assignmentRuleData.assignmentRuleName"
required
maxlength="50"
@change="validateAssignmentRule('assignmentRuleName')"
/>
<ErrorMessage
:message="assignmentRuleErrors.assignmentRuleName"
class="mt-2"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Priority')" />
<Popover>
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
@click="togglePopover()"
>
<div>
{{
priorityOptions.find(
(option) => option.value == assignmentRuleData.priority,
)?.label
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.priority = option.value
togglePopover()
}
"
>
{{ option.label }}
<FeatherIcon
v-if="assignmentRuleData.priority == option.value"
name="check"
class="size-4"
/>
</div>
</div>
</template>
</Popover>
</div>
<div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
:placeholder="__('Description')"
:label="__('Description')"
required
maxlength="250"
@change="validateAssignmentRule('description')"
v-model="assignmentRuleData.description"
/>
<ErrorMessage
:message="assignmentRuleErrors.description"
class="mt-2"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Apply on')" />
<Select
:options="[
{
label: 'Lead',
value: 'CRM Lead',
},
{
label: 'Deal',
value: 'CRM Deal',
},
]"
v-model="assignmentRuleData.documentType"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignment condition')
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__('Choose which tickets are affected by this assignment rule.')
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/crm/assignment-rule"
target="_blank"
>{{ __('Learn about conditions') }}</a
>
</span>
<div v-if="isOldSla && step.data">
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
>
<span>{{ __('Old Condition') }}</span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI && assignmentRuleData.assignCondition"
>
<span class="text-p-sm">
{{ __('Conditions for this rule were created from') }}
<a :href="deskUrl" target="_blank" class="underline">{{
__('desk')
}}</a>
{{
__(
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
)
}}
</span>
<Button
:label="__('I understand, add conditions')"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.assignConditionJson"
name="assignCondition"
:errors="assignmentRuleErrors.assignConditionError"
:doctype="assignmentRuleData.documentType"
v-else
/>
<div class="flex justify-end">
<ErrorMessage
:message="assignmentRuleErrors.assignCondition"
class="mt-2"
/>
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Unassignment condition')
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__(
'Choose which tickets are affected by this un-assignment rule.',
)
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/crm/assignment-rule"
target="_blank"
>{{ __('Learn about conditions') }}</a
>
</span>
<div
v-if="isOldSla && step.data && assignmentRuleData.unassignCondition"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
>
<span> {{ __('Old Condition') }} </span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI && assignmentRuleData.unassignCondition"
>
<span class="text-p-sm">
{{ __('Conditions for this rule were created from') }}
<a :href="deskUrl" target="_blank" class="underline">{{
__('desk')
}}</a>
{{
__(
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
)
}}
</span>
<Button
:label="__('I understand, add conditions')"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.unassignConditionJson"
name="unassignCondition"
:errors="assignmentRuleErrors.unassignConditionError"
:doctype="assignmentRuleData.documentType"
v-else
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignment Schedule')
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__('Choose the days of the week when this rule should be active.')
}}
</span>
</div>
<div class="mt-6">
<AssignmentSchedule />
</div>
</div>
<hr class="my-8" />
<AssigneeRules />
</div>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="() => (showConfirmDialog.show = false)"
/>
</template>
<script setup>
import {
Badge,
Button,
call,
createResource,
ErrorMessage,
FormControl,
FormLabel,
LoadingIndicator,
Popover,
Select,
Switch,
toast,
} from 'frappe-ui'
import { onMounted, onUnmounted, ref, inject, watch, provide } from 'vue'
import AssignmentRulesSection from './AssignmentRulesSection.vue'
import AssignmentSchedule from './AssignmentSchedule.vue'
import AssigneeRules from './AssigneeRules.vue'
import ConfirmDialog from 'frappe-ui/src/components/ConfirmDialog.vue'
import { globalStore } from '@/stores/global'
import { disableSettingModalOutsideClick } from '@/composables/settings'
import { convertToConditions, validateConditions } from '@/utils'
const isDirty = ref(false)
const initialData = ref(null)
const isLoading = ref(false)
const updateStep = inject('updateStep')
const step = inject('step')
const { $dialog } = globalStore()
const showConfirmDialog = ref({
show: false,
title: '',
message: '',
onConfirm: () => {},
})
const useNewUI = ref(true)
const isOldSla = ref(false)
const deskUrl = `${window.location.origin}/app/assignment-rule/${step.value.data?.name}`
const defaultAssignmentDays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
const assignmentRuleData = ref({
assignCondition: '',
unassignCondition: '',
assignConditionJson: [],
unassignConditionJson: [],
rule: 'Round Robin',
priority: 1,
users: [],
disabled: false,
description: '',
name: '',
assignmentRuleName: '',
assignmentDays: defaultAssignmentDays,
documentType: 'CRM Lead',
})
const validateAssignmentRule = (key, skipConditionCheck = false) => {
const validateField = (field) => {
if (key && field !== key) return
switch (field) {
case 'assignmentRuleName':
if (assignmentRuleData.value.assignmentRuleName?.length == 0) {
assignmentRuleErrors.value.assignmentRuleName = __('Name is required')
} else {
assignmentRuleErrors.value.assignmentRuleName = ''
}
break
case 'description':
assignmentRuleErrors.value.description =
assignmentRuleData.value.description?.length > 0
? ''
: __('Description is required')
break
case 'assignCondition':
if (skipConditionCheck) {
break
}
assignmentRuleErrors.value.assignCondition =
assignmentRuleData.value.assignConditionJson?.length > 0
? ''
: __('Assign condition is required')
if (!validateConditions(assignmentRuleData.value.assignConditionJson)) {
assignmentRuleErrors.value.assignConditionError = __(
'Assign conditions are invalid',
)
} else {
assignmentRuleErrors.value.assignConditionError = ''
}
break
case 'unassignCondition':
if (skipConditionCheck) {
break
}
if (
assignmentRuleData.value.unassignConditionJson?.length > 0 &&
!validateConditions(assignmentRuleData.value.unassignConditionJson)
) {
assignmentRuleErrors.value.unassignConditionError = __(
'Unassign conditions are invalid',
)
} else {
assignmentRuleErrors.value.unassignConditionError = ''
}
break
case 'users':
assignmentRuleErrors.value.users =
assignmentRuleData.value.users?.length > 0
? ''
: __('Users are required')
break
case 'assignmentDays':
assignmentRuleErrors.value.assignmentDays =
assignmentRuleData.value.assignmentDays?.length > 0
? ''
: __('Assignment days are required')
break
default:
break
}
}
if (key) {
validateField(key)
} else {
Object.keys(assignmentRuleErrors.value).forEach(validateField)
}
return assignmentRuleErrors.value
}
const resetAssignmentRuleData = () => {
assignmentRuleData.value = {
assignCondition: '',
unassignCondition: '',
assignConditionJson: [],
unassignConditionJson: [],
rule: 'Round Robin',
priority: 1,
users: [],
disabled: false,
description: '',
name: '',
assignmentRuleName: '',
assignmentDays: defaultAssignmentDays,
documentType: 'CRM Lead',
}
}
const assignmentRuleErrors = ref({
assignmentRuleName: '',
assignCondition: '',
assignConditionError: '',
unassignConditionError: '',
users: '',
description: '',
assignmentDays: '',
})
const resetAssignmentRuleErrors = () => {
Object.keys(assignmentRuleErrors.value).forEach((key) => {
assignmentRuleErrors.value[key] = ''
})
}
provide('assignmentRuleData', assignmentRuleData)
provide('assignmentRuleErrors', assignmentRuleErrors)
provide('validateAssignmentRule', validateAssignmentRule)
provide('resetAssignmentRuleData', resetAssignmentRuleData)
provide('resetAssignmentRuleErrors', resetAssignmentRuleErrors)
const getAssignmentRuleData = createResource({
url: 'frappe.client.get',
params: {
doctype: 'Assignment Rule',
name: step.value.data?.name,
},
auto: Boolean(step.value.data),
onSuccess(data) {
assignmentRuleData.value = {
assignCondition: data.assign_condition,
unassignCondition: data.unassign_condition,
assignConditionJson: JSON.parse(data.assign_condition_json || '[]'),
unassignConditionJson: JSON.parse(data.unassign_condition_json || '[]'),
rule: data.rule,
priority: data.priority,
users: data.users,
disabled: data.disabled,
description: data.description,
name: data.name,
assignmentRuleName: data.name,
assignmentDays: data.assignment_days.map((day) => day.day),
documentType: data.document_type,
}
initialData.value = JSON.stringify(assignmentRuleData.value)
const conditionsAvailable =
assignmentRuleData.value.assignCondition?.length > 0
const conditionsJsonAvailable =
assignmentRuleData.value.assignConditionJson?.length > 0
if (conditionsAvailable && !conditionsJsonAvailable) {
useNewUI.value = false
isOldSla.value = true
} else {
useNewUI.value = true
isOldSla.value = false
}
},
})
if (!step.value.data) {
initialData.value = JSON.stringify(assignmentRuleData.value)
}
const goBack = () => {
if (isDirty.value && !showConfirmDialog.value.show) {
$dialog({
title: __('Unsaved changes'),
message: __(
'Are you sure you want to go back? Unsaved changes will be lost.',
),
variant: 'solid',
actions: [
{
label: __('Go back'),
variant: 'solid',
onClick: (close) => {
updateStep('list', null)
close()
},
},
],
})
return
}
updateStep('list', null)
showConfirmDialog.value.show = false
}
const saveAssignmentRule = () => {
const validationErrors = validateAssignmentRule(undefined, !useNewUI.value)
if (Object.values(validationErrors).some((error) => error)) {
toast.error(
__('Invalid fields, check if all are filled in and values are correct.'),
)
return
}
if (step.value.data) {
if (isOldSla.value && useNewUI.value) {
showConfirmDialog.value = {
show: true,
title: __('Confirm overwrite'),
message: __(
'Your old condition will be overwritten. Are you sure you want to save?',
),
onConfirm: () => {
updateAssignmentRule()
showConfirmDialog.value.show = false
},
}
return
}
updateAssignmentRule()
} else {
createAssignmentRule()
}
}
const createAssignmentRule = () => {
isLoading.value = true
createResource({
url: 'frappe.client.insert',
params: {
doc: {
doctype: 'Assignment Rule',
document_type: assignmentRuleData.value.documentType,
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
name: assignmentRuleData.value.assignmentRuleName,
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
assign_condition: convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
}),
unassign_condition: convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
}),
assign_condition_json: JSON.stringify(
assignmentRuleData.value.assignConditionJson,
),
unassign_condition_json: JSON.stringify(
assignmentRuleData.value.unassignConditionJson,
),
},
},
auto: true,
onSuccess(data) {
getAssignmentRuleData
.submit({
doctype: 'Assignment Rule',
name: data.name,
})
.then(() => {
isLoading.value = false
toast.success(__('Assignment rule created'))
})
updateStep('view', data)
},
onError: () => {
isLoading.value = false
},
})
}
const priorityOptions = [
{ label: 'Low', value: '0' },
{ label: 'Low-Medium', value: '1' },
{ label: 'Medium', value: '2' },
{ label: 'Medium-High', value: '3' },
{ label: 'High', value: '4' },
]
const updateAssignmentRule = async () => {
isLoading.value = true
await call('frappe.client.set_value', {
doctype: 'Assignment Rule',
name: assignmentRuleData.value.name,
fieldname: {
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
document_type: assignmentRuleData.value.documentType,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
assign_condition: useNewUI.value
? convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
})
: assignmentRuleData.value.assignCondition,
unassign_condition: useNewUI.value
? convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
})
: assignmentRuleData.value.unassignCondition,
assign_condition_json: useNewUI.value
? JSON.stringify(assignmentRuleData.value.assignConditionJson)
: null,
unassign_condition_json: useNewUI.value
? JSON.stringify(assignmentRuleData.value.unassignConditionJson)
: null,
},
}).catch((er) => {
const error =
er?.messages?.[0] ||
__('Some error occurred while updating assignment rule')
toast.error(error)
isLoading.value = false
})
if (
assignmentRuleData.value.name !==
assignmentRuleData.value.assignmentRuleName
) {
await call('frappe.client.rename_doc', {
doctype: 'Assignment Rule',
old_name: assignmentRuleData.value.name,
new_name: assignmentRuleData.value.assignmentRuleName,
}).catch(async (er) => {
const error =
er?.messages?.[0] ||
__('Some error occurred while renaming assignment rule')
toast.error(error)
// Reset assignment rule to previous state
await getAssignmentRuleData.reload()
isLoading.value = false
})
await getAssignmentRuleData.submit({
doctype: 'Assignment Rule',
name: assignmentRuleData.value.assignmentRuleName,
})
} else {
getAssignmentRuleData.reload()
}
isLoading.value = false
toast.success(__('Assignment rule updated'))
}
watch(
assignmentRuleData,
(newVal) => {
if (!initialData.value) return
isDirty.value = JSON.stringify(newVal) != initialData.value
if (isDirty.value) {
disableSettingModalOutsideClick.value = true
} else {
disableSettingModalOutsideClick.value = false
}
},
{ deep: true },
)
const beforeUnloadHandler = (event) => {
if (!isDirty.value) return
event.preventDefault()
event.returnValue = true
}
onMounted(() => {
addEventListener('beforeunload', beforeUnloadHandler)
})
onUnmounted(() => {
resetAssignmentRuleErrors()
resetAssignmentRuleData()
removeEventListener('beforeunload', beforeUnloadHandler)
disableSettingModalOutsideClick.value = false
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<div class="px-10 py-8 sticky top-0">
<div class="flex items-start justify-between">
<div class="flex flex-col gap-1">
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __('Assignment rules') }}
</h1>
<p class="text-p-sm text-ink-gray-6 max-w-md">
{{
__(
'Assignment Rules automatically route tickets to the right team members based on predefined conditions.',
)
}}
</p>
</div>
<Button
:label="__('Create new')"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
/>
</div>
</div>
<div class="overflow-y-auto px-10 pb-8">
<AssignmentRulesList />
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import AssignmentRulesList from './AssignmentRulesList.vue'
import { inject, provide } from 'vue'
const updateStep = inject('updateStep')
const assignmentRulesListData = createResource({
url: 'crm.api.assignment_rule.get_assignment_rules_list',
cache: ['assignmentRules', 'get_assignment_rules_list'],
auto: true,
})
provide('assignmentRulesList', assignmentRulesListData)
const goToNew = () => {
updateStep('view', null)
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<div
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<div
v-if="assignmentRulesList.data?.length === 0"
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
>
<div class="text-sm text-ink-gray-7">
{{ __('No items in the list') }}
</div>
</div>
<div v-else>
<div class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600">
<div class="col-span-7 ml-2">{{ __('Assignment rule') }}</div>
<div class="col-span-2">{{ __('Priority') }}</div>
<div class="col-span-2">{{ __('Enabled') }}</div>
</div>
<hr class="mt-2 mx-2" />
<div
v-for="assignmentRule in assignmentRulesList.data"
:key="assignmentRule.name"
>
<AssignmentRuleListItem :data="assignmentRule" />
<hr class="mx-2" />
</div>
</div>
</div>
</template>
<script setup>
import { LoadingIndicator } from 'frappe-ui'
import { inject } from 'vue'
import AssignmentRuleListItem from './AssignmentRuleListItem.vue'
const assignmentRulesList = inject('assignmentRulesList')
</script>

View File

@ -0,0 +1,96 @@
<template>
<CFConditions
v-if="props.conditions.length > 0"
:conditions="props.conditions"
:level="0"
:disableAddCondition="props.errors !== ''"
:doctype="props.doctype"
/>
<div
v-if="props.conditions.length == 0"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-gray-300 text-gray-600 rounded-md"
@click="
() => {
props.conditions.push(['', '', ''])
validateAssignmentRule(props.name)
}
"
>
<FeatherIcon name="plus" class="h-4" />
{{ __('Add a condition') }}
</div>
<div class="flex items-center justify-between mt-2">
<div class="" v-if="props.conditions.length > 0">
<Dropdown v-slot="{ open }" :options="dropdownOptions">
<Button
:disabled="props.errors !== ''"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
:label="__('Add condition')"
/>
</Dropdown>
</div>
<ErrorMessage v-if="props.conditions.length > 0" :message="props.errors" />
</div>
</template>
<script setup>
import { Button, Dropdown, ErrorMessage, FeatherIcon } from 'frappe-ui'
import { watchDebounced } from '@vueuse/core'
import { validateConditions } from '@/utils'
import CFConditions from '../../ConditionsFilter/CFConditions.vue'
import { inject } from 'vue'
const props = defineProps({
conditions: Array,
name: String,
errors: String,
doctype: String,
})
const validateAssignmentRule = inject('validateAssignmentRule')
const getConjunction = () => {
let conjunction = 'and'
props.conditions.forEach((condition) => {
if (typeof condition == 'string') {
conjunction = condition
}
})
return conjunction
}
const dropdownOptions = [
{
label: __('Add condition'),
onClick: () => {
addCondition()
},
},
{
label: __('Add condition group'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, [[]])
},
},
]
const addCondition = () => {
const isValid = validateConditions(props.conditions)
if (!isValid) {
return
}
const conjunction = getConjunction()
props.conditions.push(conjunction, ['', '', ''])
}
watchDebounced(
() => [...props.conditions],
() => {
validateAssignmentRule(props.name)
},
{ deep: true, debounce: 300 },
)
</script>

View File

@ -0,0 +1,85 @@
<template>
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div
class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
>
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ column.label }}
</div>
</div>
<hr />
<AssignmentScheduleItem
v-for="(day, index) in days"
:key="day.day"
:data="day"
:isLast="index === days.length - 1"
/>
</div>
<ErrorMessage :message="assignmentRuleErrors.assignmentDays" class="mt-2" />
</template>
<script setup>
import { ErrorMessage } from 'frappe-ui'
import { onMounted, ref } from 'vue'
import AssignmentScheduleItem from './AssignmentScheduleItem.vue'
import { inject } from 'vue'
const assignmentRuleData = inject('assignmentRuleData')
const assignmentRuleErrors = inject('assignmentRuleErrors')
const columns = [
{
label: 'Days',
key: 'day',
},
{
label: 'Active',
key: 'active',
},
]
const days = ref([
{
day: 'Monday',
active: false,
},
{
day: 'Tuesday',
active: false,
},
{
day: 'Wednesday',
active: false,
},
{
day: 'Thursday',
active: false,
},
{
day: 'Friday',
active: false,
},
{
day: 'Saturday',
active: false,
},
{
day: 'Sunday',
active: false,
},
])
onMounted(() => {
assignmentRuleData.value.assignmentDays.forEach((day) => {
const workDay = days.value.find((d) => d.day === day)
if (workDay) {
workDay.active = true
}
})
})
</script>

View File

@ -0,0 +1,42 @@
<template>
<div
class="grid py-3.5 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
>
<div class="text-ink-gray-7 font-medium">{{ data.day }}</div>
<div class="flex justify-start">
<Switch v-model="data.active" @update:model-value="toggleDay" />
</div>
</div>
<hr v-if="!isLast" />
</template>
<script setup>
import { Switch } from 'frappe-ui'
import { inject } from 'vue'
const assignmentRuleData = inject('assignmentRuleData')
const props = defineProps({
data: {
type: Object,
required: true,
},
isLast: {
type: Boolean,
default: false,
},
})
const toggleDay = (isActive) => {
const dayIndex = assignmentRuleData.value.assignmentDays.findIndex(
(d) => d === props.data.day,
)
if (isActive && dayIndex === -1) {
assignmentRuleData.value.assignmentDays.push(props.data.day)
} else {
assignmentRuleData.value.assignmentDays.splice(dayIndex, 1)
}
}
</script>

View File

@ -3,6 +3,7 @@
v-model="showSettings"
:options="{ size: '5xl' }"
@close="activeSettingsPage = ''"
:disableOutsideClickToClose="disableSettingModalOutsideClick"
>
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
@ -46,6 +47,7 @@ import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import SettingsIcon2 from '@/components/Icons/SettingsIcon2.vue'
import Users from '@/components/Settings/Users.vue'
import GeneralSettingsPage from '@/components/Settings/General/GeneralSettingsPage.vue'
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
@ -61,9 +63,11 @@ import {
isWhatsappInstalled,
showSettings,
activeSettingsPage,
disableSettingModalOutsideClick,
} from '@/composables/settings'
import { Dialog, Avatar } from 'frappe-ui'
import { ref, markRaw, computed, watch, h } from 'vue'
import AssignmentRulePage from './AssignmentRules/AssignmentRulePage.vue'
const { isManager, isTelephonyAgent, getUser } = usersStore()
@ -114,6 +118,11 @@ const tabs = computed(() => {
icon: EmailTemplateIcon,
component: markRaw(EmailTemplatePage),
},
{
label: __('Assignment rules'),
icon: markRaw(h(SettingsIcon2, { class: 'rotate-90' })),
component: markRaw(AssignmentRulePage),
},
],
},
{

View File

@ -41,4 +41,7 @@ export const mobileSidebarOpened = ref(false)
export const isMobileView = computed(() => window.innerWidth < 768)
export const showSettings = ref(false)
export const disableSettingModalOutsideClick = ref(false)
export const activeSettingsPage = ref('')

View File

@ -529,3 +529,163 @@ export function copy(obj) {
if (!obj) return obj
return JSON.parse(JSON.stringify(obj))
}
export const convertToConditions = ({ conditions, fieldPrefix }) => {
if (!conditions || conditions.length === 0) {
return ''
}
const processCondition = (condition) => {
if (typeof condition === 'string') {
return condition.toLowerCase()
}
if (Array.isArray(condition)) {
// Nested condition group
if (Array.isArray(condition[0])) {
const nestedStr = convertToConditions({
conditions: condition,
fieldPrefix,
})
return `(${nestedStr})`
}
// Simple condition: [fieldname, operator, value]
const [field, operator, value] = condition
const fieldAccess = fieldPrefix ? `${fieldPrefix}.${field}` : field
const operatorMap = {
equals: '==',
'=': '==',
'==': '==',
'!=': '!=',
'not equals': '!=',
'<': '<',
'<=': '<=',
'>': '>',
'>=': '>=',
in: 'in',
'not in': 'not in',
like: 'like',
'not like': 'not like',
is: 'is',
'is not': 'is not',
between: 'between',
}
let op = operatorMap[operator.toLowerCase()] || operator
if (
(op === '==' || op === '!=') &&
(String(value).toLowerCase() === 'yes' ||
String(value).toLowerCase() === 'no')
) {
let checkVal = String(value).toLowerCase() === 'yes'
if (op === '!=') {
checkVal = !checkVal
}
return checkVal ? fieldAccess : `not ${fieldAccess}`
}
if (op === 'is' && String(value).toLowerCase() === 'set') {
return fieldAccess
}
if (
(op === 'is' && String(value).toLowerCase() === 'not set') ||
(op === 'is not' && String(value).toLowerCase() === 'set')
) {
return `not ${fieldAccess}`
}
if (op === 'like') {
return `(${fieldAccess} and "${value}" in ${fieldAccess})`
}
if (op === 'not like') {
return `(${fieldAccess} and "${value}" not in ${fieldAccess})`
}
if (
op === 'between' &&
typeof value === 'string' &&
value.includes(',')
) {
const [start, end] = value.split(',').map((v) => v.trim())
return `(${fieldAccess} >= "${start}" and ${fieldAccess} <= "${end}")`
}
let valueStr = ''
if (op === 'in' || op === 'not in') {
let items
if (Array.isArray(value)) {
items = value.map((v) => `"${String(v).trim()}"`)
} else if (typeof value === 'string') {
items = value.split(',').map((v) => `"${v.trim()}"`)
} else {
items = [`"${String(value).trim()}"`]
}
valueStr = `[${items.join(', ')}]`
return `(${fieldAccess} and ${fieldAccess} ${op} ${valueStr})`
}
if (typeof value === 'string') {
valueStr = `"${value.replace(/"/g, '\\"')}"`
} else if (typeof value === 'number' || typeof value === 'boolean') {
valueStr = String(value)
} else if (value === null || value === undefined) {
return op === '==' || op === 'is' ? `not ${fieldAccess}` : fieldAccess
} else {
valueStr = `"${String(value).replace(/"/g, '\\"')}"`
}
return `${fieldAccess} ${op} ${valueStr}`
}
return ''
}
const parts = conditions.map(processCondition)
return parts.join(' ')
}
export function validateConditions(conditions) {
if (!Array.isArray(conditions)) return false
// Handle simple condition [field, operator, value]
if (
conditions.length === 3 &&
typeof conditions[0] === 'string' &&
typeof conditions[1] === 'string'
) {
return conditions[0] !== '' && conditions[1] !== '' && conditions[2] !== ''
}
// Iterate through conditions and logical operators
for (let i = 0; i < conditions.length; i++) {
const item = conditions[i]
// Skip logical operators (they will be validated by their position)
if (item === 'and' || item === 'or') {
// Ensure logical operators are not at start/end and not consecutive
if (
i === 0 ||
i === conditions.length - 1 ||
conditions[i - 1] === 'and' ||
conditions[i - 1] === 'or'
) {
return false
}
continue
}
// Handle nested conditions (arrays)
if (Array.isArray(item)) {
if (!validateConditions(item)) {
return false
}
} else if (item !== undefined && item !== null) {
return false
}
}
return conditions.length > 0
}

2216
yarn.lock

File diff suppressed because it is too large Load Diff