1
0
forked from test/crm

Merge pull request #222 from shariquerik/quick-entry-layout-builder

feat: Quick Entry Layout Builder
This commit is contained in:
Shariq Ansari 2024-06-19 19:22:40 +05:30 committed by GitHub
commit 9da213c799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 524 additions and 54 deletions

View File

@ -8,31 +8,40 @@
"engine": "InnoDB",
"field_order": [
"organization_tab",
"naming_series",
"organization",
"website",
"territory",
"annual_revenue",
"close_date",
"probability",
"next_step",
"probability",
"column_break_ijan",
"status",
"close_date",
"deal_owner",
"contacts_tab",
"contacts",
"contact",
"lead_details_tab",
"lead",
"source",
"column_break_wsde",
"lead_name",
"organization_details_section",
"organization_name",
"website",
"no_of_employees",
"job_title",
"column_break_xbyf",
"territory",
"annual_revenue",
"industry",
"person_section",
"salutation",
"first_name",
"last_name",
"column_break_xjmy",
"email",
"mobile_no",
"phone",
"contacts",
"others_tab",
"naming_series",
"status",
"section_break_sygz",
"no_of_employees",
"column_break_nwob",
"job_title",
"section_break_eepu",
"lead",
"column_break_bqvs",
"source",
"gender",
"sla_tab",
"sla",
"sla_creation",
@ -88,14 +97,6 @@
"label": "Lead",
"options": "CRM Lead"
},
{
"fieldname": "section_break_eepu",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bqvs",
"fieldtype": "Column Break"
},
{
"fieldname": "deal_owner",
"fieldtype": "Link",
@ -142,12 +143,6 @@
"label": "Contacts",
"options": "CRM Contacts"
},
{
"fieldname": "others_tab",
"fieldtype": "Tab Break",
"label": "Others",
"read_only": 1
},
{
"fieldname": "organization_tab",
"fieldtype": "Tab Break",
@ -235,14 +230,6 @@
"label": "No. of Employees",
"options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
},
{
"fieldname": "section_break_sygz",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_nwob",
"fieldtype": "Column Break"
},
{
"fieldname": "job_title",
"fieldtype": "Data",
@ -270,11 +257,81 @@
"fieldname": "lead_name",
"fieldtype": "Data",
"label": "Lead Name"
},
{
"fieldname": "column_break_ijan",
"fieldtype": "Column Break"
},
{
"fieldname": "lead_details_tab",
"fieldtype": "Tab Break",
"label": "Lead Details"
},
{
"fieldname": "column_break_wsde",
"fieldtype": "Column Break"
},
{
"fieldname": "organization_details_section",
"fieldtype": "Section Break",
"label": "Organization Details"
},
{
"fieldname": "organization_name",
"fieldtype": "Data",
"label": "Organization Name"
},
{
"fieldname": "column_break_xbyf",
"fieldtype": "Column Break"
},
{
"fieldname": "industry",
"fieldtype": "Link",
"label": "Industry",
"options": "CRM Industry"
},
{
"fieldname": "person_section",
"fieldtype": "Section Break",
"label": "Person"
},
{
"fieldname": "salutation",
"fieldtype": "Link",
"label": "Salutation",
"options": "Salutation"
},
{
"fieldname": "first_name",
"fieldtype": "Data",
"label": "First Name"
},
{
"fieldname": "last_name",
"fieldtype": "Data",
"label": "Last Name"
},
{
"fieldname": "column_break_xjmy",
"fieldtype": "Column Break"
},
{
"fieldname": "gender",
"fieldtype": "Link",
"label": "Gender",
"options": "Gender"
},
{
"fieldname": "contact",
"fieldtype": "Link",
"label": "Contact",
"options": "Contact"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-25 21:00:08.216020",
"modified": "2024-06-19 18:01:59.213811",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -113,19 +113,19 @@ def add_default_fields_layout():
layouts = {
"CRM Lead-Quick Entry": {
"doctype": "CRM Lead",
"layout": '[\n{\n"label": "Person",\n\t"fields": ["salutation", "first_name", "last_name", "email", "mobile_no", "gender"]\n},\n{\n"label": "Organization",\n\t"fields": ["organization", "website", "no_of_employees", "territory", "annual_revenue", "industry"]\n},\n{\n"label": "Other",\n"columns": 2,\n\t"fields": ["status", "lead_owner"]\n}\n]'
"layout": '[{"label":"Person","fields":["salutation","first_name","last_name","email","mobile_no", "gender"],"hideLabel":true},{"label":"Organization","fields":["organization","website","no_of_employees","territory","annual_revenue","industry"],"hideLabel":true,"hideBorder":false},{"label":"Other","columns":2,"fields":["status","lead_owner"],"hideLabel":true,"hideBorder":false}]'
},
"CRM Deal-Quick Entry": {
"doctype": "CRM Deal",
"layout": '[\n{\n"label": "Select Organization",\n\t"fields": ["organization"]\n},\n{\n"label": "Organization Details",\n\t"fields": [{"label": "Organization Name", "name": "organization_name", "type": "Data"}, "website", "no_of_employees", "territory", "annual_revenue", {"label": "Industry", "name": "industry", "type": "Link", "options": "CRM Industry"}]\n},\n{\n"label": "Select Contact",\n\t"fields": [{"label": "Contact", "name": "contact", "type": "Link", "options": "Contact"}]\n},\n{\n"label": "Contact Details",\n\t"fields": [{"label": "Salutation", "name": "salutation", "type": "Link", "options": "Salutation"}, {"label": "First Name", "name": "first_name", "type": "Data"}, {"label": "Last Name", "name": "last_name", "type": "Data"}, "email", "mobile_no", {"label": "Gender", "name": "gender", "type": "Link", "options": "Gender"}]\n},\n{\n"label": "Other",\n"columns": 2,\n\t"fields": ["status", "deal_owner"]\n}\n]'
"layout": '[{"label": "Select Organization", "fields": ["organization"], "hideLabel": true, "editable": true}, {"label": "Organization Details", "fields": ["organization_name", "website", "no_of_employees", "territory", "annual_revenue", "industry"], "hideLabel": true, "editable": true}, {"label": "Select Contact", "fields": ["contact"], "hideLabel": true, "editable": true}, {"label": "Contact Details", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no", "gender"], "hideLabel": true, "editable": true}, {"label": "Other", "columns": 2, "fields": ["status", "deal_owner"], "hideLabel": true}]'
},
"Contact-Quick Entry": {
"doctype": "Contact",
"layout": '[\n{\n"label": "Salutation",\n"columns": 1,\n"fields": ["salutation"]\n},\n{\n"label": "Full Name",\n"columns": 2,\n"hideBorder": true,\n"fields": ["first_name", "last_name"]\n},\n{\n"label": "Email",\n"columns": 1,\n"hideBorder": true,\n"fields": ["email_id"]\n},\n{\n"label": "Mobile No. & Gender",\n"columns": 2,\n"hideBorder": true,\n"fields": ["mobile_no", "gender"]\n},\n{\n"label": "Organization",\n"columns": 1,\n"hideBorder": true,\n"fields": ["company_name"]\n},\n{\n"label": "Designation",\n"columns": 1,\n"hideBorder": true,\n"fields": ["designation"]\n}\n]'
"layout": '[{"label":"Salutation","columns":1,"fields":["salutation"],"hideLabel":true},{"label":"Full Name","columns":2,"hideBorder":true,"fields":["first_name","last_name"],"hideLabel":true},{"label":"Email","columns":1,"hideBorder":true,"fields":["email_id"],"hideLabel":true},{"label":"Mobile No. & Gender","columns":2,"hideBorder":true,"fields":["mobile_no","gender"],"hideLabel":true},{"label":"Organization","columns":1,"hideBorder":true,"fields":["company_name"],"hideLabel":true},{"label":"Designation","columns":1,"hideBorder":true,"fields":["designation"],"hideLabel":true}]'
},
"Organization-Quick Entry": {
"doctype": "CRM Organization",
"layout": '[\n{\n"label": "Organization Name",\n"columns": 1,\n"fields": ["organization_name"]\n},\n{\n"label": "Website & Revenue",\n"columns": 2,\n"hideBorder": true,\n"fields": ["website", "annual_revenue"]\n},\n{\n"label": "Territory",\n"columns": 1,\n"hideBorder": true,\n"fields": ["territory"]\n},\n{\n"label": "No of Employees & Industry",\n"columns": 2,\n"hideBorder": true,\n"fields": ["no_of_employees", "industry"]\n}\n]'
"layout": '[{"label":"Organization Name","columns":1,"fields":["organization_name"],"hideLabel":true},{"label":"Website & Revenue","columns":2,"hideBorder":true,"fields":["website","annual_revenue"],"hideLabel":true},{"label":"Territory","columns":1,"hideBorder":true,"fields":["territory"],"hideLabel":true},{"label":"No of Employees & Industry","columns":2,"hideBorder":true,"fields":["no_of_employees","industry"],"hideLabel":true}]'
},
}

View File

@ -7,4 +7,5 @@ crm.patches.v1_0.move_crm_note_data_to_fcrm_note
# Patches added in this section will be executed after doctypes are migrated
crm.patches.v1_0.create_email_template_custom_fields
crm.patches.v1_0.create_default_fields_layout
crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout

View File

@ -0,0 +1,15 @@
import json
import frappe
def execute():
if not frappe.db.exists("CRM Fields Layout", "CRM Deal-Quick Entry"):
return
deal = frappe.db.get_value("CRM Fields Layout", "CRM Deal-Quick Entry", "layout")
layout = json.loads(deal)
for section in layout:
if section.get("label") in ["Select Organization", "Organization Details", "Select Contact", "Contact Details"]:
section["editable"] = True
frappe.db.set_value("CRM Fields Layout", "CRM Deal-Quick Entry", "layout", json.dumps(layout))

View File

@ -6,6 +6,12 @@
class="first:border-t-0 first:pt-0"
:class="section.hideBorder ? '' : 'border-t pt-4'"
>
<div
v-if="!section.hideLabel"
class="flex h-7 mb-3 max-w-fit cursor-pointer items-center gap-2 text-base font-semibold leading-5"
>
{{ section.label }}
</div>
<div
class="grid gap-4"
:class="

View File

@ -0,0 +1,18 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-panel-right-close"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>
</template>

View File

@ -36,7 +36,7 @@
<FormControl
v-else-if="
['email', 'number', 'date', 'password', 'textarea'].includes(
field.type
field.type,
)
"
class="form-control"
@ -137,7 +137,10 @@ const _fields = computed(() => {
props.fields?.forEach((field) => {
let df = field.all_properties
if (df?.depends_on) evaluate_depends_on(df.depends_on, field)
all_fields.push(field)
all_fields.push({
...field,
placeholder: field.placeholder || field.label,
})
})
return all_fields
})

View File

@ -0,0 +1,113 @@
<template>
<div class="flex flex-col overflow-hidden">
<div class="flex flex-col gap-2 p-8 pb-5">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
<div>{{ __('Quick Entry Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h2>
<div class="flex gap-6 items-end">
<FormControl
class="flex-1"
type="select"
v-model="doctype"
:label="__('DocType')"
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
@change="reload"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
<Button
:label="preview ? __('Hide Preview') : __('Show Preview')"
@click="preview = !preview"
/>
</div>
</div>
</div>
<div v-if="sections?.data" class="overflow-y-auto p-8 pt-3">
<div
class="rounded-xl h-full inline-block w-full px-4 pb-6 pt-5 sm:px-6 transform overflow-y-auto bg-white text-left align-middle shadow-xl transition-all"
>
<QuickEntryLayoutBuilder
v-if="!preview"
:sections="sections.data"
:doctype="doctype"
/>
<Fields v-else :sections="sections.data" :data="{}" />
</div>
</div>
</div>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import QuickEntryLayoutBuilder from '@/components/Settings/QuickEntryLayoutBuilder.vue'
import { useDebounceFn } from '@vueuse/core'
import { Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted } from 'vue'
const doctype = ref('CRM Lead')
const loading = ref(false)
const dirty = ref(false)
const preview = ref(false)
function getParams() {
return { doctype: doctype.value, type: 'Quick Entry' }
}
const sections = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quick-entry-sections', doctype.value],
params: getParams(),
onSuccess(data) {
sections.originalData = JSON.parse(JSON.stringify(data))
},
})
watch(
() => sections?.data,
() => {
dirty.value =
JSON.stringify(sections?.data) !== JSON.stringify(sections?.originalData)
},
{ deep: true },
)
onMounted(() => useDebounceFn(reload, 100)())
function reload() {
sections.params = getParams()
sections.reload()
}
function saveChanges() {
let _sections = JSON.parse(JSON.stringify(sections.data))
_sections.forEach((section) => {
if (!section.fields) return
section.fields = section.fields.map(
(field) => field.fieldname || field.name,
)
})
loading.value = true
call(
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
{
doctype: doctype.value,
type: 'Quick Entry',
layout: JSON.stringify(_sections),
},
).then(() => {
loading.value = false
reload()
})
}
</script>

View File

@ -0,0 +1,220 @@
<template>
<div>
<Draggable :list="sections" item-key="label" class="flex flex-col">
<template #item="{ element: section }">
<div
class="py-2 first:pt-0"
:class="section.hideBorder ? '' : 'border-t first:border-t-0'"
>
<div class="flex items-center justify-between pb-2">
<div
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 text-base font-semibold leading-5"
>
<div
v-if="!section.editingLabel"
:class="section.hideLabel ? 'text-gray-400' : ''"
>
{{ __(section.label) || __('Untitled') }}
</div>
<div v-else class="flex gap-2 items-center">
<Input
v-model="section.label"
@keydown.enter="section.editingLabel = false"
@blur="section.editingLabel = false"
@click.stop
/>
<Button
v-if="section.editingLabel"
icon="check"
variant="ghost"
@click="section.editingLabel = false"
/>
</div>
</div>
<Dropdown :options="getOptions(section)">
<template #default>
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="h-4" />
</Button>
</template>
</Dropdown>
</div>
<div>
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="grid gap-2"
:class="
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<div
class="px-1.5 py-1 border rounded text-base text-gray-800 flex items-center justify-between gap-2"
>
<div class="flex items-center gap-2">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<div>
<Button
variant="ghost"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</div>
</div>
</template>
</Draggable>
<Autocomplete
v-if="fields.data"
value=""
:options="fields.data"
@change="(e) => addField(section, e)"
>
<template #target="{ togglePopover }">
<div
class="grid gap-2 w-full"
:class="
section.columns
? 'grid-cols-' + section.columns
: 'grid-cols-3'
"
>
<Button
class="mt-2 w-full !h-[38px] !border-gray-200"
variant="outline"
@click="togglePopover()"
:label="__('Add Field')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</template>
<template #item-label="{ option }">
<div class="flex flex-col gap-1">
<div>{{ option.label }}</div>
<div class="text-gray-500 text-sm">
{{ `${option.fieldname} - ${option.fieldtype}` }}
</div>
</div>
</template>
</Autocomplete>
</div>
</div>
</template>
</Draggable>
<div class="py-2 border-t">
<Button
class="w-full !h-[38px] !border-gray-200"
variant="outline"
:label="__('Add Section')"
@click="
sections.push({
label: __('New Section'),
opened: true,
fields: [],
})
"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Draggable from 'vuedraggable'
import { Dropdown, createResource } from 'frappe-ui'
import { computed, watch } from 'vue'
const props = defineProps({
sections: Object,
doctype: String,
})
const restrictedFieldTypes = [
'Table',
'Geolocation',
'Attach',
'Attach Image',
'HTML',
'Signature',
]
const params = computed(() => {
return {
doctype: props.doctype,
restricted_fieldtypes: restrictedFieldTypes,
as_array: true,
}
})
const fields = createResource({
url: 'crm.api.doc.get_fields_meta',
params: params.value,
cache: ['fieldsMeta', props.doctype],
auto: true,
})
function addField(section, field) {
if (!field) return
section.fields.push(field)
}
function getOptions(section) {
return [
{
label: 'Edit',
icon: 'edit',
onClick: () => (section.editingLabel = true),
condition: () => section.editable !== false,
},
{
label: section.hideLabel ? 'Show Label' : 'Hide Label',
icon: section.hideLabel ? 'eye' : 'eye-off',
onClick: () => (section.hideLabel = !section.hideLabel),
},
{
label: section.hideBorder ? 'Show Border' : 'Hide Border',
icon: 'minus',
onClick: () => (section.hideBorder = !section.hideBorder),
},
{
label: 'Add Column',
icon: 'columns',
onClick: () =>
(section.columns = section.columns ? section.columns + 1 : 4),
condition: () => !section.columns || section.columns < 4,
},
{
label: 'Remove Column',
icon: 'columns',
onClick: () =>
(section.columns = section.columns ? section.columns - 1 : 2),
condition: () => !section.columns || section.columns > 1,
},
{
label: 'Remove Section',
icon: 'trash-2',
onClick: () => props.sections.splice(props.sections.indexOf(section), 1),
condition: () => section.editable !== false,
},
]
}
watch(
() => props.doctype,
() => fields.fetch(params.value),
{ immediate: true },
)
</script>

View File

@ -40,10 +40,12 @@
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import RightSideLayoutIcon from '@/components/Icons/RightSideLayoutIcon.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
import FieldsLayout from '@/components/Settings/FieldsLayout.vue'
import SidebarFieldsLayout from '@/components/Settings/SidebarFieldsLayout.vue'
import QuickEntryLayout from '@/components/Settings/QuickEntryLayout.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { isWhatsappInstalled } from '@/composables/settings'
import { Dialog, FeatherIcon } from 'frappe-ui'
@ -84,9 +86,14 @@ const tabs = computed(() => {
label: 'Customizations',
items: [
{
label: 'Fields Layout',
label: 'Sidebar Fields Layout',
icon: RightSideLayoutIcon,
component: markRaw(SidebarFieldsLayout),
},
{
label: 'Quick Entry Layout',
icon: h(FeatherIcon, { name: 'grid' }),
component: markRaw(FieldsLayout),
component: markRaw(QuickEntryLayout),
},
],
},

View File

@ -27,6 +27,10 @@
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
<Button
:label="preview ? __('Hide Preview') : __('Show Preview')"
@click="preview = !preview"
/>
</div>
</div>
<Resizer
@ -37,13 +41,30 @@
>
<div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto">
<SidebarLayoutBuilder :sections="sections.data" :doctype="doctype" />
<SidebarLayoutBuilder
v-if="!preview"
:sections="sections.data"
:doctype="doctype"
/>
<div
v-else
v-for="(section, i) in sections.data"
:key="section.label"
class="flex flex-col p-3"
:class="{ 'border-b': i !== sections.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields :fields="section.fields" v-model="data" />
</Section>
</div>
</div>
</div>
</Resizer>
</div>
</template>
<script setup>
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import Resizer from '@/components/Resizer.vue'
import SidebarLayoutBuilder from '@/components/Settings/SidebarLayoutBuilder.vue'
import { useDebounceFn } from '@vueuse/core'
@ -54,6 +75,8 @@ const parentRef = ref(null)
const doctype = ref('CRM Lead')
const loading = ref(false)
const dirty = ref(false)
const preview = ref(false)
const data = ref({})
function getParams() {
return { doctype: doctype.value, type: 'Side Panel' }

View File

@ -16,20 +16,27 @@
<div v-if="!section.editingLabel">
{{ __(section.label) || __('Untitled') }}
</div>
<div v-else>
<div v-else class="flex gap-2 items-center">
<Input
v-model="section.label"
@keydown.enter="section.editingLabel = false"
@blur="section.editingLabel = false"
@click.stop
/>
<Button
v-if="section.editingLabel"
icon="check"
variant="ghost"
@click.stop="section.editingLabel = false"
/>
</div>
</div>
<div>
<Button
:icon="section.editingLabel ? 'check' : 'edit'"
v-if="!section.editingLabel"
icon="edit"
variant="ghost"
@click="section.editingLabel = !section.editingLabel"
@click="section.editingLabel = true"
/>
<Button
v-if="section.editable !== false"