Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-1252

This commit is contained in:
Shariq Ansari 2025-09-18 15:21:52 +05:30 committed by GitHub
commit 7ef00965fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1598 additions and 4 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

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: shariq@frappe.io\n"
"POT-Creation-Date: 2025-09-14 09:35+0000\n"
"PO-Revision-Date: 2025-09-16 20:02\n"
"PO-Revision-Date: 2025-09-17 20:27\n"
"Last-Translator: shariq@frappe.io\n"
"Language-Team: Portuguese\n"
"MIME-Version: 1.0\n"
@ -588,7 +588,7 @@ msgstr "Atribuído a"
#: frontend/src/components/Settings/AssignmentRules/AssigneeRules.vue:5
msgid "Assignee Rules"
msgstr ""
msgstr "Regras de Atribuição"
#: frontend/src/components/Settings/AssignmentRules/AssigneeRules.vue:75
msgid "Assignees"

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,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,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
}