Merge pull request #1258 from frappe/mergify/bp/main-hotfix/pr-1206
This commit is contained in:
commit
a360fa774b
32
crm/api/assignment_rule.py
Normal file
32
crm/api/assignment_rule.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
9
crm/patches/v1_0/add_fields_in_assignment_rule.py
Normal file
9
crm/patches/v1_0/add_fields_in_assignment_rule.py
Normal 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
3
frontend/.gitignore
vendored
@ -2,4 +2,5 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.local
|
||||
components.d.ts
|
||||
469
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal file
469
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal 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>
|
||||
142
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal file
142
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal 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>
|
||||
17
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal file
17
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal 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
|
||||
},
|
||||
})
|
||||
19
frontend/src/components/Icons/SettingsIcon2.vue
Normal file
19
frontend/src/components/Icons/SettingsIcon2.vue
Normal 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>
|
||||
@ -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 they’re 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user