Shariq Ansari 8749f7bfd0 refactor: update styling and improve layout for assignment rules components
(cherry picked from commit 9f95a3a2b2132357708db03b9b4922f356150ff4)
2025-09-18 09:59:25 +00:00

783 lines
24 KiB
Vue

<template>
<div
v-if="!getAssignmentRuleData.loading"
class="flex flex-col h-full gap-6 px-6 py-8 text-ink-gray-8"
>
<div class="flex items-center justify-between px-2 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 class="overflow-y-auto px-2">
<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-outline-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 {0} are affected by this assignment rule.', [
documentType,
])
}}
<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-outline-gray-2 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 {0} are affected by this un-assignment rule.',
[documentType],
)
}}
<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
v-if="!useNewUI && assignmentRuleData.unassignCondition"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
>
<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
v-else
:conditions="assignmentRuleData.unassignConditionJson"
name="unassignCondition"
:errors="assignmentRuleErrors.unassignConditionError"
:doctype="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 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>
</div>
<div v-else class="flex items-center h-full justify-center">
<LoadingIndicator class="w-4" />
</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,
computed,
} 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 documentType = computed(() =>
assignmentRuleData.value.documentType == 'CRM Lead'
? __('leads')
: __('deals'),
)
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>