Merge pull request #228 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-06-20 15:22:47 +05:30 committed by GitHub
commit 9a01517d6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 869 additions and 288 deletions

View File

@ -73,6 +73,7 @@ def get_linked_deals(contact):
fields=[
"name",
"organization",
"currency",
"annual_revenue",
"status",
"email",

View File

@ -257,17 +257,18 @@ def get_list_data(
"user": frappe.session.user,
}
_list = get_controller(doctype)
if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters):
list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters)
columns = frappe.parse_json(list_view_settings.columns)
rows = frappe.parse_json(list_view_settings.rows)
is_default = False
elif not custom_view or is_default:
_list = get_controller(doctype)
elif not custom_view or is_default and hasattr(_list, "default_list_data"):
columns = _list.default_list_data().get("columns")
if hasattr(_list, "default_list_data"):
columns = _list.default_list_data().get("columns")
rows = _list.default_list_data().get("rows")
if hasattr(_list, "default_list_data"):
rows = _list.default_list_data().get("rows")
# check if rows has all keys from columns if not add them
for column in columns:

View File

@ -8,31 +8,41 @@
"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",
"currency",
"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",
@ -63,7 +73,8 @@
"fetch_from": ".annual_revenue",
"fieldname": "annual_revenue",
"fieldtype": "Currency",
"label": "Amount"
"label": "Amount",
"options": "currency"
},
{
"fetch_from": ".website",
@ -88,14 +99,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 +145,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 +232,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 +259,87 @@
"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"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-25 21:00:08.216020",
"modified": "2024-06-20 12:55:41.602364",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -178,6 +178,7 @@ class CRMDeal(Document):
"annual_revenue",
"status",
"email",
"currency",
"mobile_no",
"deal_owner",
"sla_status",

View File

@ -9,6 +9,7 @@
"field_order": [
"organization_name",
"no_of_employees",
"currency",
"annual_revenue",
"organization_logo",
"column_break_pnpp",
@ -47,7 +48,8 @@
{
"fieldname": "annual_revenue",
"fieldtype": "Currency",
"label": "Annual Revenue"
"label": "Annual Revenue",
"options": "currency"
},
{
"fieldname": "industry",
@ -60,12 +62,18 @@
"fieldtype": "Link",
"label": "Territory",
"options": "CRM Territory"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
}
],
"image_field": "organization_logo",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-19 21:53:14.945857",
"modified": "2024-06-20 12:59:55.297752",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Organization",

View File

@ -47,6 +47,7 @@ class CRMOrganization(Document):
"organization_logo",
"website",
"industry",
"currency",
"annual_revenue",
"modified",
]

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"] = False
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-circle-dollar-sign"
>
<circle cx="12" cy="12" r="10" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 18V6" />
</svg>
</template>

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

@ -147,15 +147,15 @@ const dealStatuses = computed(() => {
})
function createDeal() {
if (deal.website && !deal.website.startsWith('http')) {
deal.website = 'https://' + deal.website
}
createResource({
url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal',
params: { args: deal },
auto: true,
validate() {
error.value = null
if (deal.website && !deal.website.startsWith('http')) {
deal.website = 'https://' + deal.website
}
if (deal.annual_revenue) {
deal.annual_revenue = deal.annual_revenue.replace(/,/g, '')
if (isNaN(deal.annual_revenue)) {
@ -182,6 +182,14 @@ function createDeal() {
show.value = false
router.push({ name: 'Deal', params: { dealId: name } })
},
onError(err) {
isDealCreating.value = false
if (!err.messages) {
error.value = err.message
return
}
error.value = err.messages.join('\n')
},
})
}

View File

@ -97,6 +97,10 @@ const leadStatuses = computed(() => {
})
function createNewLead() {
if (lead.website && !lead.website.startsWith('http')) {
lead.website = 'https://' + lead.website
}
createLead.submit(lead, {
validate() {
error.value = null
@ -104,9 +108,6 @@ function createNewLead() {
error.value = __('First Name is mandatory')
return error.value
}
if (lead.website && !lead.website.startsWith('http')) {
lead.website = 'https://' + lead.website
}
if (lead.annual_revenue) {
lead.annual_revenue = lead.annual_revenue.replace(/,/g, '')
if (isNaN(lead.annual_revenue)) {
@ -133,6 +134,14 @@ function createNewLead() {
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })
},
onError(err) {
isLeadCreating.value = false
if (!err.messages) {
error.value = err.message
return
}
error.value = err.messages.join('\n')
},
})
}

View File

@ -61,9 +61,11 @@
<script setup>
import Fields from '@/components/Fields.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import MoneyIcon from '@/components/Icons/MoneyIcon.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import { formatNumberIntoCurrency } from '@/utils'
import { call, FeatherIcon, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { useRouter } from 'vue-router'
@ -205,9 +207,12 @@ const fields = computed(() => {
value: _organization.value.territory,
},
{
icon: h(FeatherIcon, { name: 'dollar-sign', class: 'h-4 w-4' }),
icon: MoneyIcon,
name: 'annual_revenue',
value: _organization.value.annual_revenue,
value: formatNumberIntoCurrency(
_organization.value.annual_revenue,
_organization.value.currency,
),
},
{
icon: h(FeatherIcon, { name: 'hash', class: 'h-4 w-4' }),
@ -227,7 +232,7 @@ const fields = computed(() => {
const sections = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'CRM Organization'],
params: { doctype: 'CRM Organization', type: 'Quick Entry'},
params: { doctype: 'CRM Organization', type: 'Quick Entry' },
auto: true,
})
@ -246,6 +251,6 @@ watch(
editMode.value = true
}
})
}
},
)
</script>

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

@ -1,94 +0,0 @@
<template>
<div ref="parentRef" class="flex h-full">
<div class="flex-1 flex flex-col justify-between gap-2 p-8">
<div class="flex flex-col gap-2">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
<div>{{ __('Sidebar Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h2>
<FormControl
type="select"
v-model="doctype"
:label="__('DocType')"
:options="['CRM Lead', 'CRM Deal']"
/>
</div>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="sections.reload" />
</div>
</div>
<Resizer
class="flex flex-col justify-between border-l"
:parent="parentRef"
side="right"
>
<div
v-if="sections.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<SidebarLayoutBuilder :sections="sections.data" :doctype="doctype" />
</div>
</div>
</Resizer>
</div>
</template>
<script setup>
import Resizer from '@/components/Resizer.vue'
import SidebarLayoutBuilder from '@/components/Settings/SidebarLayoutBuilder.vue'
import { Badge, call, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
const parentRef = ref(null)
const doctype = ref('CRM Lead')
const oldSections = ref([])
const sections = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['sidebar-sections', doctype.value],
params: { doctype: doctype.value, type: 'Side Panel' },
auto: true,
onSuccess(data) {
oldSections.value = JSON.parse(JSON.stringify(data))
},
})
const loading = ref(false)
const dirty = computed(() => {
if (!sections.data) return false
return JSON.stringify(sections.data) !== JSON.stringify(oldSections.value)
})
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: 'Side Panel',
layout: JSON.stringify(_sections),
}).then(() => {
loading.value = false
sections.reload()
})
}
watch(doctype, (val) => sections.fetch({ doctype: val, type: 'Side Panel' }), {
immediate: true,
})
</script>

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

@ -3,42 +3,31 @@
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
<h1 class="px-2 pt-2 text-lg font-semibold">
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }}
</h1>
<nav class="mt-3 space-y-1">
<SidebarLink
v-for="tab in tabs"
:icon="tab.icon"
:label="__(tab.label)"
class="w-full"
:class="
activeTab?.label == tab.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100'
"
@click="activeTab = tab"
/>
</nav>
<div
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
>
<span>{{ __('Integrations') }}</span>
<div v-for="tab in tabs">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<SidebarLink
v-for="i in tab.items"
:icon="i.icon"
:label="__(i.label)"
class="w-full"
:class="
activeTab?.label == i.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100'
"
@click="activeTab = i"
/>
</nav>
</div>
<nav class="space-y-1">
<SidebarLink
v-for="i in integrations"
:icon="i.icon"
:label="__(i.label)"
class="w-full"
:class="
activeTab?.label == i.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100'
"
@click="activeTab = i"
/>
</nav>
</div>
<div class="flex flex-1 flex-col overflow-y-auto">
<component :is="activeTab.component" v-if="activeTab" />
@ -51,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'
@ -62,38 +53,62 @@ import { ref, markRaw, computed, h } from 'vue'
const show = defineModel()
let tabs = [
{
label: 'Profile',
icon: ContactsIcon,
component: markRaw(ProfileSettings),
},
{
label: 'Fields Layout',
icon: h(FeatherIcon, { name: 'grid' }),
component: markRaw(FieldsLayout),
},
]
let integrations = computed(() => {
let items = [
const tabs = computed(() => {
let _tabs = [
{
label: 'Twilio',
icon: PhoneIcon,
component: markRaw(TwilioSettings),
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Profile',
icon: ContactsIcon,
component: markRaw(ProfileSettings),
},
],
},
{
label: 'Integrations',
items: [
{
label: 'Twilio',
icon: PhoneIcon,
component: markRaw(TwilioSettings),
},
{
label: 'WhatsApp',
icon: WhatsAppIcon,
component: markRaw(WhatsAppSettings),
condition: () => isWhatsappInstalled.value,
},
],
},
{
label: 'Customizations',
items: [
{
label: 'Sidebar Fields Layout',
icon: RightSideLayoutIcon,
component: markRaw(SidebarFieldsLayout),
},
{
label: 'Quick Entry Layout',
icon: h(FeatherIcon, { name: 'grid' }),
component: markRaw(QuickEntryLayout),
},
],
},
]
if (isWhatsappInstalled.value) {
items.push({
label: 'WhatsApp',
icon: WhatsAppIcon,
component: markRaw(WhatsAppSettings),
return _tabs.map((tab) => {
tab.items = tab.items.filter((item) => {
if (item.condition) {
return item.condition()
}
return true
})
}
return items
return tab
})
})
const activeTab = ref(tabs[0])
const activeTab = ref(tabs.value[0].items[0])
</script>

View File

@ -0,0 +1,131 @@
<template>
<div ref="parentRef" class="flex h-full">
<div class="flex-1 flex flex-col justify-between gap-2 p-8">
<div class="flex flex-col gap-2">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
<div>{{ __('Sidebar Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h2>
<FormControl
type="select"
v-model="doctype"
:label="__('DocType')"
:options="['CRM Lead', 'CRM Deal']"
@change="reload"
/>
</div>
<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>
<Resizer
v-if="sections.data"
class="flex flex-col justify-between border-l"
:parent="parentRef"
side="right"
>
<div class="flex flex-1 flex-col justify-between overflow-hidden">
<div class="flex flex-col overflow-y-auto">
<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'
import { Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted } from 'vue'
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' }
}
const sections = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['sidebar-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: 'Side Panel',
layout: JSON.stringify(_sections),
},
).then(() => {
loading.value = false
reload()
})
}
</script>

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"
@ -42,6 +49,7 @@
<div v-show="section.opened" class="p-4 pt-0 pb-2">
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="flex flex-col gap-1"
handle=".cursor-grab"
@ -111,9 +119,13 @@
variant="outline"
:label="__('Add Section')"
@click="
sections.push({ label: 'New Section', opened: true, fields: [] })
sections.push({ label: __('New Section'), opened: true, fields: [] })
"
/>
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</template>

View File

@ -7,8 +7,8 @@
isCollapsed
? 'w-auto px-0'
: open
? 'w-52 bg-white px-2 shadow-sm'
: 'w-52 px-2 hover:bg-gray-200'
? 'w-52 bg-white px-2 shadow-sm'
: 'w-52 px-2 hover:bg-gray-200'
"
>
<CRMLogo class="size-8 flex-shrink-0 rounded" />
@ -44,7 +44,7 @@
</button>
</template>
</Dropdown>
<SettingsModal v-model="showSettingsModal" />
<SettingsModal v-if="showSettingsModal" v-model="showSettingsModal" />
</template>
<script setup>

View File

@ -357,7 +357,10 @@ function getDealRowObject(deal) {
label: deal.organization,
logo: getOrganization(deal.organization)?.organization_logo,
},
annual_revenue: formatNumberIntoCurrency(deal.annual_revenue),
annual_revenue: formatNumberIntoCurrency(
deal.annual_revenue,
deal.currency,
),
status: {
label: deal.status,
color: getDealStatus(deal.status)?.iconColorClass,

View File

@ -488,7 +488,7 @@ const fieldsLayout = createResource({
transform: (data) => getParsedFields(data),
})
function getParsedFields(sections, contacts) {
function getParsedFields(sections) {
sections.forEach((section) => {
if (section.name == 'contacts_section') return
section.fields.forEach((field) => {
@ -583,7 +583,7 @@ const deal_contacts = createResource({
cache: ['deal_contacts', props.dealId],
auto: true,
onSuccess: (data) => {
let contactSection = fieldsLayout.data.find(
let contactSection = fieldsLayout.data?.find(
(section) => section.name == 'contacts_section',
)
if (!contactSection) return

View File

@ -109,7 +109,7 @@ const rows = computed(() => {
if (!deals.value?.data.group_by_field?.name) return []
return getGroupedByRows(
deals.value?.data.data,
deals.value?.data.group_by_field
deals.value?.data.group_by_field,
)
} else {
return parseRows(deals.value?.data.data)
@ -158,7 +158,10 @@ function parseRows(rows) {
logo: getOrganization(deal.organization)?.organization_logo,
}
} else if (row == 'annual_revenue') {
_rows[row] = formatNumberIntoCurrency(deal.annual_revenue)
_rows[row] = formatNumberIntoCurrency(
deal.annual_revenue,
deal.currency,
)
} else if (row == 'status') {
_rows[row] = {
label: deal.status,
@ -171,8 +174,8 @@ function parseRows(rows) {
deal.sla_status == 'Failed'
? 'red'
: deal.sla_status == 'Fulfilled'
? 'green'
: 'orange'
? 'green'
: 'orange'
if (value == 'First Response Due') {
value = __(timeAgo(deal.response_by))
tooltipText = dateFormat(deal.response_by, dateTooltipFormat)
@ -207,7 +210,7 @@ function parseRows(rows) {
}
} else if (
['first_response_time', 'first_responded_on', 'response_by'].includes(
row
row,
)
) {
let field = row == 'response_by' ? 'response_by' : 'first_responded_on'

View File

@ -58,15 +58,15 @@
@updateField="updateField"
/>
<div
v-if="detailSections.length"
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections"
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== detailSections.length - 1 }"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<template #actions>
@ -441,44 +441,31 @@ const tabs = computed(() => {
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
})
const detailSections = computed(() => {
let data = deal.data
if (!data) return []
return getParsedFields(data.doctype_fields, deal_contacts.data)
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.dealId],
params: { doctype: 'CRM Deal', name: props.dealId },
auto: true,
transform: (data) => getParsedFields(data),
})
function getParsedFields(sections, contacts) {
function getParsedFields(sections) {
sections.forEach((section) => {
if (section.name == 'contacts_tab') {
delete section.fields
section.contacts =
contacts?.map((contact) => {
return {
name: contact.name,
full_name: contact.full_name,
email: contact.email,
mobile_no: contact.mobile_no,
image: contact.image,
is_primary: contact.is_primary,
opened: false,
}
}) || []
} else {
section.fields.forEach((field) => {
if (field.name == 'organization') {
field.create = (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
}
field.link = (org) =>
router.push({
name: 'Organization',
params: { organizationId: org },
})
if (section.name == 'contacts_section') return
section.fields.forEach((field) => {
if (field.name == 'organization') {
field.create = (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
}
})
}
field.link = (org) =>
router.push({
name: 'Organization',
params: { organizationId: org },
})
}
})
})
return sections
}
@ -556,6 +543,23 @@ const deal_contacts = createResource({
params: { name: props.dealId },
cache: ['deal_contacts', props.dealId],
auto: true,
onSuccess: (data) => {
let contactSection = fieldsLayout.data?.find(
(section) => section.name == 'contacts_section',
)
if (!contactSection) return
contactSection.contacts = data.map((contact) => {
return {
name: contact.name,
full_name: contact.full_name,
email: contact.email,
mobile_no: contact.mobile_no,
image: contact.image,
is_primary: contact.is_primary,
opened: false,
}
})
},
})
function updateField(name, value, callback) {

View File

@ -63,15 +63,15 @@
@updateField="updateField"
/>
<div
v-if="detailSections.length"
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections"
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== detailSections.length - 1 }"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
@ -367,10 +367,11 @@ watch(tabs, (value) => {
}
})
const detailSections = computed(() => {
let data = lead.data
if (!data) return []
return data.doctype_fields
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.leadId],
params: { doctype: 'CRM Lead', name: props.leadId },
auto: true,
})
function updateField(name, value, callback) {

View File

@ -103,8 +103,13 @@
v-if="organization.doc.annual_revenue"
class="flex items-center gap-1.5"
>
<FeatherIcon name="dollar-sign" class="h-4 w-4" />
<span class="">{{ organization.doc.annual_revenue }}</span>
<MoneyIcon class="size-4" />
<span class="">{{
formatNumberIntoCurrency(
organization.doc.annual_revenue,
organization.doc.currency,
)
}}</span>
</div>
<span
v-if="organization.doc.annual_revenue"
@ -231,6 +236,7 @@ import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import MoneyIcon from '@/components/Icons/MoneyIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
@ -347,6 +353,7 @@ const deals = createListResource({
fields: [
'name',
'organization',
'currency',
'annual_revenue',
'status',
'email',
@ -405,7 +412,10 @@ function getDealRowObject(deal) {
label: deal.organization,
logo: props.organization?.organization_logo,
},
annual_revenue: formatNumberIntoCurrency(deal.annual_revenue),
annual_revenue: formatNumberIntoCurrency(
deal.annual_revenue,
deal.currency,
),
status: {
label: deal.status,
color: getDealStatus(deal.status)?.iconColorClass,

View File

@ -85,7 +85,7 @@ const showOrganizationModal = ref(false)
const currentOrganization = computed(() => {
return organizations.value?.data?.data?.find(
(organization) => organization.name === route.params.organizationId
(organization) => organization.name === route.params.organizationId,
)
})
@ -124,7 +124,10 @@ const rows = computed(() => {
} else if (row === 'website') {
_rows[row] = website(organization.website)
} else if (row === 'annual_revenue') {
_rows[row] = formatNumberIntoCurrency(organization.annual_revenue)
_rows[row] = formatNumberIntoCurrency(
organization.annual_revenue,
organization.currency,
)
} else if (['modified', 'creation'].includes(row)) {
_rows[row] = {
label: dateFormat(organization[row], dateTooltipFormat),

View File

@ -94,12 +94,12 @@ export function secondsToDuration(seconds) {
return `${hours}h ${minutes}m ${_seconds}s`
}
export function formatNumberIntoCurrency(value) {
export function formatNumberIntoCurrency(value, currency = 'INR') {
if (value) {
return value.toLocaleString('en-IN', {
maximumFractionDigits: 2,
maximumFractionDigits: 0,
style: 'currency',
currency: 'INR',
currency: currency ? currency : 'INR',
})
}
return ''