1
0
forked from test/crm

Merge pull request #29 from shariquerik/custom-fields

feat: Custom fields
This commit is contained in:
Shariq Ansari 2023-11-22 18:16:00 +05:30 committed by GitHub
commit b58523a433
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 496 additions and 626 deletions

View File

@ -45,3 +45,79 @@ def get_filterable_fields(doctype: str):
res = [] res = []
res.extend(from_doc_fields) res.extend(from_doc_fields)
return res return res
@frappe.whitelist()
def get_doctype_fields(doctype):
not_allowed_fieldtypes = [
"Section Break",
"Column Break",
]
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
sections = {}
section_fields = []
last_section = None
for field in fields:
if field.fieldtype == "Tab Break" and last_section:
sections[last_section]["fields"] = section_fields
last_section = None
if field.read_only:
section_fields = []
continue
if field.fieldtype == "Tab Break":
if field.read_only:
section_fields = []
continue
section_fields = []
last_section = field.fieldname
sections[field.fieldname] = {
"label": field.label,
"opened": True,
"fields": [],
}
else:
section_fields.append(get_field_obj(field))
all_fields = []
for section in sections:
all_fields.append(sections[section])
return all_fields
def get_field_obj(field):
obj = {
"label": field.label,
"type": get_type(field),
"name": field.fieldname,
}
obj["placeholder"] = "Add " + field.label.lower() + "..."
if field.fieldtype == "Link":
obj["placeholder"] = "Select " + field.label.lower() + "..."
obj["doctype"] = field.options
elif field.fieldtype == "Select":
obj["options"] = [{"label": option, "value": option} for option in field.options.split("\n")]
if field.read_only:
obj["tooltip"] = "This field is read only and cannot be edited."
return obj
def get_type(field):
if field.fieldtype == "Data" and field.options == "Phone":
return "phone"
elif field.fieldtype == "Data" and field.options == "Email":
return "email"
elif field.fieldtype == "Check":
return "checkbox"
elif field.fieldtype == "Int":
return "number"
elif field.fieldtype in ["Small Text", "Text", "Long Text"]:
return "textarea"
elif field.read_only:
return "read_only"
return field.fieldtype.lower()

View File

@ -6,23 +6,24 @@
"doctype": "DocType", "doctype": "DocType",
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"naming_series", "organization_tab",
"organization", "organization",
"website", "website",
"annual_revenue", "annual_revenue",
"column_break_afce",
"deal_owner",
"close_date", "close_date",
"status",
"probability", "probability",
"next_step", "next_step",
"section_break_eepu",
"lead",
"column_break_bqvs",
"contacts_tab", "contacts_tab",
"email", "email",
"mobile_no", "mobile_no",
"contacts" "contacts",
"others_tab",
"naming_series",
"status",
"deal_owner",
"section_break_eepu",
"lead",
"column_break_bqvs"
], ],
"fields": [ "fields": [
{ {
@ -39,19 +40,17 @@
{ {
"fetch_from": "organization.annual_revenue", "fetch_from": "organization.annual_revenue",
"fieldname": "annual_revenue", "fieldname": "annual_revenue",
"fieldtype": "Int", "fieldtype": "Currency",
"label": "Annual Revenue" "label": "Amount",
}, "read_only": 1
{
"fieldname": "column_break_afce",
"fieldtype": "Column Break"
}, },
{ {
"fetch_from": "organization.website", "fetch_from": "organization.website",
"fieldname": "website", "fieldname": "website",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website", "label": "Website",
"options": "URL" "options": "URL",
"read_only": 1
}, },
{ {
"fieldname": "close_date", "fieldname": "close_date",
@ -92,7 +91,8 @@
{ {
"fieldname": "contacts_tab", "fieldname": "contacts_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Contacts" "label": "Contacts",
"read_only": 1
}, },
{ {
"fieldname": "email", "fieldname": "email",
@ -121,11 +121,22 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "Contacts", "label": "Contacts",
"options": "CRM Contacts" "options": "CRM Contacts"
},
{
"fieldname": "others_tab",
"fieldtype": "Tab Break",
"label": "Others",
"read_only": 1
},
{
"fieldname": "organization_tab",
"fieldtype": "Tab Break",
"label": "Organization"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-11-09 19:58:15.620483", "modified": "2023-11-22 17:52:31.595525",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal", "name": "CRM Deal",

View File

@ -1,20 +1,12 @@
import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.desk.form.load import get_docinfo
@frappe.whitelist() @frappe.whitelist()
def get_lead(name): def get_lead(name):
Lead = frappe.qb.DocType("CRM Lead") Lead = frappe.qb.DocType("CRM Lead")
query = ( query = frappe.qb.from_(Lead).select("*").where(Lead.name == name).limit(1)
frappe.qb.from_(Lead)
.select("*")
.where(Lead.name == name)
.limit(1)
)
lead = query.run(as_dict=True) lead = query.run(as_dict=True)
if not len(lead): if not len(lead):

View File

@ -8,36 +8,32 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"naming_series", "details",
"salutation", "organization",
"first_name", "website",
"middle_name", "industry",
"last_name",
"column_break_izjs",
"lead_name",
"gender",
"image",
"column_break_lcuv",
"lead_owner",
"status",
"job_title", "job_title",
"source", "source",
"converted", "person_tab",
"salutation",
"first_name",
"last_name",
"email",
"mobile_no",
"organization_tab", "organization_tab",
"section_break_uixv", "section_break_uixv",
"organization", "naming_series",
"no_of_employees", "lead_name",
"middle_name",
"gender",
"phone",
"column_break_dbsv", "column_break_dbsv",
"website", "status",
"lead_owner",
"no_of_employees",
"annual_revenue", "annual_revenue",
"industry", "image",
"contact_tab", "converted"
"section_break_ymew",
"email",
"column_break_sijm",
"mobile_no",
"column_break_sjtw",
"phone"
], ],
"fields": [ "fields": [
{ {
@ -67,20 +63,12 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Last Name" "label": "Last Name"
}, },
{
"fieldname": "column_break_lcuv",
"fieldtype": "Column Break"
},
{ {
"fieldname": "gender", "fieldname": "gender",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Gender", "label": "Gender",
"options": "Gender" "options": "Gender"
}, },
{
"fieldname": "column_break_izjs",
"fieldtype": "Column Break"
},
{ {
"default": "Open", "default": "Open",
"fieldname": "status", "fieldname": "status",
@ -91,10 +79,6 @@
"reqd": 1, "reqd": 1,
"search_index": 1 "search_index": 1
}, },
{
"fieldname": "section_break_ymew",
"fieldtype": "Section Break"
},
{ {
"fieldname": "email", "fieldname": "email",
"fieldtype": "Data", "fieldtype": "Data",
@ -110,20 +94,12 @@
"options": "URL", "options": "URL",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "column_break_sijm",
"fieldtype": "Column Break"
},
{ {
"fieldname": "mobile_no", "fieldname": "mobile_no",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Mobile No", "label": "Mobile No",
"options": "Phone" "options": "Phone"
}, },
{
"fieldname": "column_break_sjtw",
"fieldtype": "Column Break"
},
{ {
"fieldname": "phone", "fieldname": "phone",
"fieldtype": "Data", "fieldtype": "Data",
@ -192,12 +168,8 @@
{ {
"fieldname": "organization_tab", "fieldname": "organization_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Organization" "label": "Others",
}, "read_only": 1
{
"fieldname": "contact_tab",
"fieldtype": "Tab Break",
"label": "Contact"
}, },
{ {
"fieldname": "organization", "fieldname": "organization",
@ -212,12 +184,22 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Converted" "label": "Converted"
},
{
"fieldname": "person_tab",
"fieldtype": "Tab Break",
"label": "Person"
},
{
"fieldname": "details",
"fieldtype": "Tab Break",
"label": "Details"
} }
], ],
"image_field": "image", "image_field": "image",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-11-13 13:35:35.783003", "modified": "2023-11-22 13:03:02.261001",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Lead", "name": "CRM Lead",

View File

@ -0,0 +1,56 @@
<template>
<slot name="header" v-bind="{ opened, open, close, toggle }">
<div class="flex items-center justify-between">
<div
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
name="chevron-right"
class="h-4 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ label || 'Untitled' }}
</div>
<slot name="actions"></slot>
</div>
</slot>
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div v-if="opened">
<slot v-bind="{ opened, open, close, toggle }" />
</div>
</transition>
</template>
<script setup>
import { FeatherIcon } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
label: {
type: String,
default: '',
},
isOpened: {
type: Boolean,
default: true,
},
})
function toggle() {
opened.value = !opened.value
}
function open() {
opened.value = true
}
function close() {
opened.value = false
}
let opened = ref(props.isOpened)
</script>

View File

@ -0,0 +1,119 @@
<template>
<div class="flex flex-col gap-1.5 max-h-[300px] overflow-y-auto">
<div
v-for="field in fields"
:key="field.label"
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
>
<div class="w-[106px] shrink-0 text-gray-600">
{{ field.label }}
</div>
<div class="grid flex-1 items-center overflow-hidden min-h-[28px]">
<FormControl
v-if="
[
'email',
'number',
'date',
'password',
'textarea',
'checkbox',
].includes(field.type)
"
class="form-control"
:type="field.type"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<FormControl
v-else-if="field.type === 'select'"
class="form-control cursor-pointer [&_select]:cursor-pointer"
type="select"
:value="data[field.name]"
:options="field.options"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="data[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
@change="(data) => emit('update', field.name, data)"
:onCreate="field.create"
/>
<Tooltip
v-else-if="field.type === 'read_only'"
class="flex h-7 cursor-pointer items-center px-2 py-1"
:text="field.tooltip"
>
{{ field.value }}
</Tooltip>
<FormControl
v-else
class="form-control"
type="text"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
</div>
<ExternalLinkIcon
v-if="field.type === 'link' && field.link && data[field.name]"
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600"
@click="field.link(data[field.name])"
/>
</div>
</div>
</template>
<script setup>
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
import Link from '@/components/Controls/Link.vue'
import { FormControl, Tooltip } from 'frappe-ui'
import { defineModel } from 'vue'
const props = defineProps({
fields: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update'])
const data = defineModel()
</script>
<style scoped>
:deep(.form-control input:not([type='checkbox'])),
:deep(.form-control select),
:deep(.form-control textarea),
:deep(.form-control button) {
border-color: transparent;
background: white;
}
:deep(.form-control button) {
gap: 0;
}
:deep(.form-control [type='checkbox']) {
margin-left: 9px;
cursor: pointer;
}
:deep(.form-control button > div) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.form-control button svg) {
color: white;
width: 0;
}
</style>

View File

@ -1,24 +0,0 @@
<template>
<slot v-bind="{ opened, open, close, toggle }"></slot>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
isOpened: {
type: Boolean,
default: true,
},
})
function toggle() {
opened.value = !opened.value
}
function open() {
opened.value = true
}
function close() {
opened.value = false
}
let opened = ref(props.isOpened)
</script>

View File

@ -96,24 +96,13 @@
<div class="flex flex-1 flex-col justify-between overflow-hidden"> <div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto"> <div class="flex flex-col overflow-y-auto">
<div <div
v-for="(section, i) in detailSections" v-for="(section, i) in detailSections.data"
:key="section.label" :key="section.label"
class="flex flex-col p-3" class="flex flex-col p-3"
:class="{ 'border-b': i !== detailSections.length - 1 }" :class="{ 'border-b': i !== detailSections.data.length - 1 }"
> >
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }"> <Section :is-opened="section.opened" :label="section.label">
<div class="flex items-center justify-between"> <template #actions>
<div
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
>
<FeatherIcon
name="chevron-right"
class="h-4 text-gray-900 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ section.label }}
</div>
<div v-if="section.contacts" class="pr-2"> <div v-if="section.contacts" class="pr-2">
<Link <Link
value="" value=""
@ -143,180 +132,103 @@
</template> </template>
</Link> </Link>
</div> </div>
</div> </template>
<transition <SectionFields
enter-active-class="duration-300 ease-in" v-if="section.fields"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]" :fields="section.fields"
enter-to-class="max-h-[200px] overflow-hidden" v-model="deal.data"
leave-from-class="max-h-[200px] overflow-hidden" @update="updateField"
enter-from-class="max-h-0 overflow-hidden" />
leave-to-class="max-h-0 overflow-hidden" <div v-else>
> <div
<div v-if="opened" class="flex flex-col gap-1.5"> v-if="section.contacts.length"
v-for="(contact, i) in section.contacts"
:key="contact.name"
>
<div <div
v-if="section.fields" class="px-2 pb-2.5"
v-for="field in section.fields" :class="[i == 0 ? 'pt-5' : 'pt-2.5']"
:key="field.label"
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
> >
<div class="w-[106px] shrink-0 text-gray-600"> <Section :is-opened="contact.opened">
{{ field.label }} <template #header="{ opened, toggle }">
</div> <div
<div class="flex-1 overflow-hidden"> class="flex cursor-pointer items-center justify-between gap-2 pr-1 text-base leading-5 text-gray-700"
<Link
v-if="field.type === 'link'"
class="form-control"
:value="deal.data[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
@change="(e) => field.change(e)"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'date'"
type="date"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
class="form-control"
/>
<Tooltip
:text="field.tooltip"
class="flex h-7 cursor-pointer items-center px-2 py-1"
v-else-if="field.type === 'read_only'"
>
{{ field.value }}
</Tooltip>
<FormControl
v-else
type="text"
:value="deal.data[field.name]"
@change.stop="
updateDeal(field.name, $event.target.value)
"
:debounce="500"
class="form-control"
/>
</div>
<ExternalLinkIcon
v-if="
field.type === 'link' &&
field.link &&
deal.data[field.name]
"
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600"
@click="field.link(deal.data[field.name])"
/>
</div>
<div v-else>
<div
v-if="section.contacts.length"
v-for="(contact, i) in section.contacts"
:key="contact.name"
>
<div
class="px-2 pb-2.5"
:class="[i == 0 ? 'pt-5' : 'pt-2.5']"
>
<Toggler
:is-opened="contact.opened"
v-slot="{ opened: cOpened, toggle: cToggle }"
> >
<div <div
class="flex cursor-pointer items-center justify-between gap-2 pr-1 text-base leading-5 text-gray-700" class="flex h-7 items-center gap-2"
@click="toggle()"
> >
<div <Avatar
class="flex h-7 items-center gap-2" :label="getContactByName(contact.name).full_name"
@click="cToggle()" :image="getContactByName(contact.name).image"
> size="md"
<Avatar />
:label=" {{ getContactByName(contact.name).full_name }}
getContactByName(contact.name).full_name <Badge
" v-if="contact.is_primary"
:image="getContactByName(contact.name).image" class="ml-2"
size="md" variant="outline"
/> label="Primary"
{{ getContactByName(contact.name).full_name }} theme="green"
<Badge />
v-if="contact.is_primary" </div>
class="ml-2" <div class="flex items-center">
variant="outline" <Dropdown :options="contactOptions(contact)">
label="Primary" <Button variant="ghost">
theme="green"
/>
</div>
<div class="flex items-center">
<Dropdown :options="contactOptions(contact)">
<Button variant="ghost">
<FeatherIcon
name="more-horizontal"
class="h-4 text-gray-600"
/>
</Button>
</Dropdown>
<Button
variant="ghost"
@click="
router.push({
name: 'Contact',
params: { contactId: contact.name },
})
"
>
<ExternalLinkIcon class="h-4 w-4" />
</Button>
<Button variant="ghost" @click="cToggle()">
<FeatherIcon <FeatherIcon
name="chevron-right" name="more-horizontal"
class="h-4 w-4 text-gray-900 transition-all duration-300 ease-in-out" class="h-4 text-gray-600"
:class="{ 'rotate-90': cOpened }"
/> />
</Button> </Button>
</div> </Dropdown>
</div> <Button
<transition variant="ghost"
enter-active-class="duration-300 ease-in" @click="
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]" router.push({
enter-to-class="max-h-[200px] overflow-hidden" name: 'Contact',
leave-from-class="max-h-[200px] overflow-hidden" params: { contactId: contact.name },
enter-from-class="max-h-0 overflow-hidden" })
leave-to-class="max-h-0 overflow-hidden" "
>
<div
v-if="cOpened"
class="flex flex-col gap-1.5 text-base text-gray-800"
> >
<div <ExternalLinkIcon class="h-4 w-4" />
class="flex items-center gap-3 pb-1.5 pl-1 pt-4" </Button>
> <Button variant="ghost" @click="toggle()">
<EmailIcon class="h-4 w-4" /> <FeatherIcon
{{ getContactByName(contact.name).email_id }} name="chevron-right"
</div> class="h-4 w-4 text-gray-900 transition-all duration-300 ease-in-out"
<div class="flex items-center gap-3 p-1 py-1.5"> :class="{ 'rotate-90': opened }"
<PhoneIcon class="h-4 w-4" /> />
{{ getContactByName(contact.name).mobile_no }} </Button>
</div> </div>
</div> </div>
</transition> </template>
</Toggler>
</div>
<div <div
v-if="i != section.contacts.length - 1" class="flex flex-col gap-1.5 text-base text-gray-800"
class="mx-2 h-px border-t border-gray-200" >
/> <div class="flex items-center gap-3 pb-1.5 pl-1 pt-4">
</div> <EmailIcon class="h-4 w-4" />
<div {{ getContactByName(contact.name).email_id }}
v-else </div>
class="flex h-20 items-center justify-center text-base text-gray-600" <div class="flex items-center gap-3 p-1 py-1.5">
> <PhoneIcon class="h-4 w-4" />
No contacts added {{ getContactByName(contact.name).mobile_no }}
</div> </div>
</div>
</Section>
</div> </div>
<div
v-if="i != section.contacts.length - 1"
class="mx-2 h-px border-t border-gray-200"
/>
</div> </div>
</transition> <div
</Toggler> v-else
class="flex h-20 items-center justify-center text-base text-gray-600"
>
No contacts added
</div>
</div>
</Section>
</div> </div>
</div> </div>
</div> </div>
@ -353,12 +265,13 @@ import LinkIcon from '@/components/Icons/LinkIcon.vue'
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue' import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
import SuccessIcon from '@/components/Icons/SuccessIcon.vue' import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue' import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue' import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import ContactModal from '@/components/Modals/ContactModal.vue' import ContactModal from '@/components/Modals/ContactModal.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import { import {
dealStatuses, dealStatuses,
statusDropdownOptions, statusDropdownOptions,
@ -474,78 +387,53 @@ const tabs = [
}, },
] ]
const detailSections = computed(() => { const detailSections = createResource({
return [ url: 'crm.api.doc.get_doctype_fields',
{ params: { doctype: 'CRM Deal' },
label: 'Organization', cache: 'dealFields',
opened: true, auto: true,
fields: [ transform: (data) => {
{ return getParsedFields(data)
label: 'Organization', },
type: 'link',
name: 'organization',
placeholder: 'Select organization',
doctype: 'CRM Organization',
change: (data) => updateField('organization', data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
link: () => {
router.push({
name: 'Organization',
params: { organizationId: organization.value.name },
})
},
},
{
label: 'Website',
type: 'read_only',
name: 'website',
value: organization.value?.website,
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Amount',
type: 'read_only',
name: 'annual_revenue',
value: organization.value?.annual_revenue,
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Close date',
type: 'date',
name: 'close_date',
},
{
label: 'Probability',
type: 'data',
name: 'probability',
},
{
label: 'Next step',
type: 'data',
name: 'next_step',
},
],
},
{
label: 'Contacts',
opened: true,
contacts: deal.data.contacts.map((contact) => {
return {
name: contact.contact,
is_primary: contact.is_primary,
opened: false,
}
}),
},
]
}) })
function getParsedFields(sections) {
sections.forEach((section) => {
section.fields.forEach((field) => {
if (['website', 'annual_revenue'].includes(field.name)) {
field.value = organization.value?.[field.name]
field.tooltip =
'This field is read-only and is fetched from the organization'
} else if (field.name == 'organization') {
field.create = (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
}
field.link = () =>
router.push({
name: 'Organization',
params: { organizationId: deal.data.organization },
})
}
})
})
let contactSection = {
label: 'Contacts',
opened: true,
contacts: deal.data.contacts.map((contact) => {
return {
name: contact.contact,
is_primary: contact.is_primary,
opened: false,
}
}),
}
return [...sections, contactSection]
}
const showContactModal = ref(false) const showContactModal = ref(false)
const _contact = ref({}) const _contact = ref({})
@ -628,26 +516,3 @@ function updateField(name, value, callback) {
}) })
} }
</script> </script>
<style scoped>
:deep(.form-control input),
:deep(.form-control button) {
border-color: transparent;
background: white;
}
:deep(.form-control button) {
gap: 0;
}
:deep(.form-control button > div) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.form-control button svg) {
color: white;
width: 0;
}
</style>

View File

@ -57,7 +57,7 @@
:validateFile="validateFile" :validateFile="validateFile"
> >
<template #default="{ openFileSelector, error }"> <template #default="{ openFileSelector, error }">
<div class="flex items-center justify-start gap-5 p-5"> <div class="flex items-center justify-start gap-5 border-b p-5">
<div class="group relative h-[88px] w-[88px]"> <div class="group relative h-[88px] w-[88px]">
<Avatar <Avatar
size="3xl" size="3xl"
@ -135,143 +135,18 @@
<div class="flex flex-1 flex-col justify-between overflow-hidden"> <div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto"> <div class="flex flex-col overflow-y-auto">
<div <div
v-for="section in detailSections" v-for="(section, i) in detailSections.data"
:key="section.label" :key="section.label"
class="flex flex-col" class="flex flex-col p-3"
:class="{ 'border-b': i !== detailSections.data.length - 1 }"
> >
<Toggler :is-opened="section.opened" v-slot="{ opened, toggle }"> <Section :is-opened="section.opened" :label="section.label">
<div class="sticky top-0 z-10 border-t bg-white p-3"> <SectionFields
<div :fields="section.fields"
class="flex max-w-fit cursor-pointer items-center gap-2 px-2 text-base font-semibold leading-5" v-model="lead.data"
@click="toggle()" @update="updateField"
> />
<FeatherIcon </Section>
name="chevron-right"
class="h-4 text-gray-600 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ section.label }}
</div>
</div>
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div v-if="opened" class="flex flex-col gap-1.5 px-3">
<div
v-for="field in section.fields"
:key="field.name"
class="flex items-center gap-2 px-3 text-base leading-5 last:mb-3"
>
<div class="w-[106px] shrink-0 text-gray-600">
{{ field.label }}
</div>
<div class="flex-1 overflow-hidden">
<FormControl
v-if="field.type === 'select'"
type="select"
:options="field.options"
:value="lead.data[field.name]"
@change.stop="
updateLead(field.name, $event.target.value)
"
:debounce="500"
class="form-control cursor-pointer [&_select]:cursor-pointer"
>
<template #prefix>
<IndicatorIcon
:class="leadStatuses[lead.data[field.name]].color"
/>
</template>
</FormControl>
<FormControl
v-else-if="field.type === 'email'"
type="email"
class="form-control"
:value="lead.data[field.name]"
@change.stop="
updateLead(field.name, $event.target.value)
"
:debounce="500"
/>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="lead.data[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
@change="(e) => field.change(e)"
:onCreate="field.create"
/>
<FormControl
v-else-if="field.type === 'user'"
type="autocomplete"
:options="activeAgents"
:value="getUser(lead.data[field.name]).full_name"
@change="
(option) => updateField('lead_owner', option.email)
"
class="form-control"
:placeholder="field.placeholder"
>
<template #target="{ togglePopover }">
<Button
variant="ghost"
@click="togglePopover()"
:label="getUser(lead.data[field.name]).full_name"
class="w-full !justify-start"
>
<template #prefix>
<UserAvatar
:user="lead.data[field.name]"
size="sm"
/>
</template>
</Button>
</template>
<template #item-prefix="{ option }">
<UserAvatar
class="mr-2"
:user="option.email"
size="sm"
/>
</template>
</FormControl>
<Tooltip
:text="field.tooltip"
class="flex h-7 cursor-pointer items-center px-2 py-1"
v-else-if="field.type === 'read_only'"
>
{{ field.value }}
</Tooltip>
<FormControl
v-else
type="text"
:value="lead.data[field.name]"
@change.stop="
updateLead(field.name, $event.target.value)
"
class="form-control"
:debounce="500"
/>
</div>
<ExternalLinkIcon
v-if="
field.type === 'link' &&
field.link &&
lead.data[field.name]
"
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600"
@click="field.link(lead.data[field.name])"
/>
</div>
</div>
</transition>
</Toggler>
</div> </div>
</div> </div>
</div> </div>
@ -298,13 +173,12 @@ import NoteIcon from '@/components/Icons/NoteIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue' import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue' import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LinkIcon from '@/components/Icons/LinkIcon.vue' import LinkIcon from '@/components/Icons/LinkIcon.vue'
import ExternalLinkIcon from '@/components/Icons/ExternalLinkIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import Toggler from '@/components/Toggler.vue'
import Activities from '@/components/Activities.vue' import Activities from '@/components/Activities.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue' import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import Link from '@/components/Controls/Link.vue' import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import { import {
leadStatuses, leadStatuses,
statusDropdownOptions, statusDropdownOptions,
@ -427,98 +301,41 @@ function validateFile(file) {
} }
} }
const detailSections = computed(() => { const detailSections = createResource({
return [ url: 'crm.api.doc.get_doctype_fields',
{ params: { doctype: 'CRM Lead' },
label: 'Details', cache: 'leadFields',
opened: true, auto: true,
fields: [ transform: (data) => {
{ return getParsedFields(data)
label: 'Organization', },
type: 'link',
name: 'organization',
placeholder: 'Select organization',
doctype: 'CRM Organization',
change: (data) => data && updateField('organization', data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
link: () =>
router.push({
name: 'Organization',
params: { organizationId: lead.data.organization },
}),
},
{
label: 'Website',
type: 'read_only',
name: 'website',
value: organization.value?.website,
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Industry',
type: 'read_only',
name: 'industry',
value: organization.value?.industry,
tooltip:
'It is a read only field, value is fetched from organization',
},
{
label: 'Job title',
type: 'data',
name: 'job_title',
},
{
label: 'Source',
type: 'link',
name: 'source',
placeholder: 'Select source...',
doctype: 'CRM Lead Source',
change: (data) => updateField('source', data),
},
],
},
{
label: 'Person',
opened: true,
fields: [
{
label: 'Salutation',
type: 'link',
name: 'salutation',
placeholder: 'Mr./Mrs./Ms...',
doctype: 'Salutation',
change: (data) => updateField('salutation', data),
},
{
label: 'First name',
type: 'data',
name: 'first_name',
},
{
label: 'Last name',
type: 'data',
name: 'last_name',
},
{
label: 'Email',
type: 'email',
name: 'email',
},
{
label: 'Mobile no.',
type: 'phone',
name: 'mobile_no',
},
],
},
]
}) })
function getParsedFields(sections) {
sections.forEach((section) => {
section.fields.forEach((field) => {
if (['website', 'industry'].includes(field.name)) {
field.value = organization.value?.[field.name]
field.tooltip =
'This field is read-only and is fetched from the organization'
} else if (field.name == 'organization') {
field.create = (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
}
field.link = () =>
router.push({
name: 'Organization',
params: { organizationId: lead.data.organization },
})
}
})
})
return sections
}
const organization = computed(() => { const organization = computed(() => {
return getOrganization(lead.data.organization) return getOrganization(lead.data.organization)
}) })
@ -540,27 +357,3 @@ function updateField(name, value, callback) {
}) })
} }
</script> </script>
<style scoped>
:deep(.form-control input),
:deep(.form-control select),
:deep(.form-control button) {
border-color: transparent;
background: white;
}
:deep(.form-control button) {
gap: 0;
}
:deep(.form-control button > div) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.form-control button svg) {
color: white;
width: 0;
}
</style>