diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index b7e4ec83..9afab159 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -24,7 +24,7 @@ class CRMDeal(Document): self.assign_agent(self.deal_owner) if self.has_value_changed("status"): add_status_change_log(self) - self.update_close_date() + self.validate_forcasting_fields() def after_insert(self): if self.deal_owner: @@ -141,6 +141,14 @@ class CRMDeal(Document): if self.status == "Won" and not self.close_date: self.close_date = frappe.utils.nowdate() + def validate_forcasting_fields(self): + self.update_close_date() + if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"): + if not self.deal_value or self.deal_value == 0: + frappe.throw(_("Deal Value is required."), frappe.MandatoryError) + if not self.close_date: + frappe.throw(_("Close Date is required."), frappe.MandatoryError) + @staticmethod def default_list_data(): columns = [ diff --git a/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json index ae026c74..d9b5f203 100644 --- a/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json +++ b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json @@ -37,13 +37,14 @@ { "fieldname": "probability", "fieldtype": "Percent", + "in_list_view": 1, "label": "Probability" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-06-11 13:00:34.518808", + "modified": "2025-07-01 12:06:42.937440", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Deal Status", diff --git a/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py index 1fd85824..34a361e9 100644 --- a/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py +++ b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py @@ -47,6 +47,13 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None fields = frappe.get_meta(doctype).fields fields = [field for field in fields if field.fieldname in allowed_fields] + required_fields = [] + + if type == "Required Fields": + required_fields = [ + field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default + ] + for tab in tabs: for section in tab.get("sections"): if section.get("columns"): @@ -60,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None handle_perm_level_restrictions(field, doctype, parent_doctype) column["fields"][column.get("fields").index(field["fieldname"])] = field + # remove field from required_fields if it is already present + if ( + type == "Required Fields" + and field.reqd + and any(f.get("fieldname") == field.get("fieldname") for f in required_fields) + ): + required_fields = [ + f for f in required_fields if f.get("fieldname") != field.get("fieldname") + ] + + if type == "Required Fields" and required_fields and tabs: + tabs[-1].get("sections").append( + { + "label": "Required Fields", + "name": "required_fields_section_" + str(random_string(4)), + "opened": True, + "hideLabel": True, + "columns": [ + { + "name": "required_fields_column_" + str(random_string(4)), + "fields": [field.as_dict() for field in required_fields], + } + ], + } + ) + return tabs or [] @@ -83,6 +116,8 @@ def get_sidepanel_sections(doctype): fields = frappe.get_meta(doctype).fields fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes] + add_forecasting_section(layout, doctype) + for section in layout: section["name"] = section.get("name") or section.get("label") for column in section.get("columns") if section.get("columns") else []: @@ -100,6 +135,38 @@ def get_sidepanel_sections(doctype): return layout +def add_forecasting_section(layout, doctype): + if ( + doctype == "CRM Deal" + and frappe.db.get_single_value("FCRM Settings", "enable_forecasting") + and not any(section.get("name") == "forecasted_sales_section" for section in layout) + ): + contacts_section_index = next( + ( + i + for i, section in enumerate(layout) + if section.get("name") == "contacts_section" or section.get("label") == "Contacts" + ), + None, + ) + + if contacts_section_index is not None: + layout.insert( + contacts_section_index + 1, + { + "name": "forecasted_sales_section", + "label": "Forecasted Sales", + "opened": True, + "columns": [ + { + "name": "column_" + str(random_string(4)), + "fields": ["close_date", "probability", "deal_value"], + } + ], + }, + ) + + def handle_perm_level_restrictions(field, doctype, parent_doctype=None): if field.permlevel == 0: return diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json index de27e245..e679b023 100644 --- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json @@ -60,7 +60,7 @@ }, { "default": "0", - "description": "It will make deal's \"Expected Closure Date\" mandatory to get accurate forecasting insights", + "description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights", "fieldname": "enable_forecasting", "fieldtype": "Check", "label": "Enable Forecasting" @@ -69,7 +69,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-06-11 19:12:16.762499", + "modified": "2025-07-01 13:20:48.757603", "modified_by": "Administrator", "module": "FCRM", "name": "FCRM Settings", diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py index f22114f1..1460c265 100644 --- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py @@ -39,6 +39,11 @@ class FCRMSettings(Document): "reqd", "close_date", ) + delete_property_setter( + "CRM Deal", + "reqd", + "deal_value", + ) else: make_property_setter( "CRM Deal", @@ -47,6 +52,13 @@ class FCRMSettings(Document): 1 if self.enable_forecasting else 0, "Check", ) + make_property_setter( + "CRM Deal", + "deal_value", + "reqd", + 1 if self.enable_forecasting else 0, + "Check", + ) def get_standard_dropdown_items(): diff --git a/crm/install.py b/crm/install.py index 5ffa5a67..9f8e617d 100644 --- a/crm/install.py +++ b/crm/install.py @@ -68,30 +68,37 @@ def add_default_deal_statuses(): statuses = { "Qualification": { "color": "gray", + "probability": 10, "position": 1, }, "Demo/Making": { "color": "orange", + "probability": 25, "position": 2, }, "Proposal/Quotation": { "color": "blue", + "probability": 50, "position": 3, }, "Negotiation": { "color": "yellow", + "probability": 70, "position": 4, }, "Ready to Close": { "color": "purple", + "probability": 90, "position": 5, }, "Won": { "color": "green", + "probability": 100, "position": 6, }, "Lost": { "color": "red", + "probability": 0, "position": 7, }, } @@ -103,6 +110,7 @@ def add_default_deal_statuses(): doc = frappe.new_doc("CRM Deal Status") doc.deal_status = status doc.color = statuses[status]["color"] + doc.probability = statuses[status]["probability"] doc.position = statuses[status]["position"] doc.insert() diff --git a/crm/patches.txt b/crm/patches.txt index ee56be84..484c73dc 100644 --- a/crm/patches.txt +++ b/crm/patches.txt @@ -12,4 +12,5 @@ crm.patches.v1_0.create_default_sidebar_fields_layout crm.patches.v1_0.update_deal_quick_entry_layout crm.patches.v1_0.update_layouts_to_new_format crm.patches.v1_0.move_twilio_agent_to_telephony_agent -crm.patches.v1_0.create_default_scripts # 13-06-2025 \ No newline at end of file +crm.patches.v1_0.create_default_scripts # 13-06-2025 +crm.patches.v1_0.update_deal_status_probabilities \ No newline at end of file diff --git a/crm/patches/v1_0/update_deal_status_probabilities.py b/crm/patches/v1_0/update_deal_status_probabilities.py new file mode 100644 index 00000000..3460784e --- /dev/null +++ b/crm/patches/v1_0/update_deal_status_probabilities.py @@ -0,0 +1,24 @@ +import frappe + + +def execute(): + deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "probability", "deal_status"]) + + for status in deal_statuses: + if status.probability is None or status.probability == 0: + if status.deal_status == "Qualification": + probability = 10 + elif status.deal_status == "Demo/Making": + probability = 25 + elif status.deal_status == "Proposal/Quotation": + probability = 50 + elif status.deal_status == "Negotiation": + probability = 70 + elif status.deal_status == "Ready to Close": + probability = 90 + elif status.deal_status == "Won": + probability = 100 + else: + probability = 0 + + frappe.db.set_value("CRM Deal Status", status.name, "probability", probability) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index d4d287e4..984d8e3a 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -56,6 +56,7 @@ declare module 'vue' { ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default'] ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default'] ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default'] + ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.vue')['default'] CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default'] CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default'] CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default'] diff --git a/frontend/src/components/Modals/AddressModal.vue b/frontend/src/components/Modals/AddressModal.vue index 444490c4..c1e9ba20 100644 --- a/frontend/src/components/Modals/AddressModal.vue +++ b/frontend/src/components/Modals/AddressModal.vue @@ -121,7 +121,10 @@ const callBacks = { loading.value = false if (err.exc_type == 'MandatoryError') { const errorMessage = err.messages - .map((msg) => msg.split(': ')[2].trim()) + .map((msg) => { + let arr = msg.split(': ') + return arr[arr.length - 1].trim() + }) .join(', ') error.value = __('These fields are required: {0}', [errorMessage]) return diff --git a/frontend/src/components/Modals/CallLogModal.vue b/frontend/src/components/Modals/CallLogModal.vue index 1c90541f..dab2374c 100644 --- a/frontend/src/components/Modals/CallLogModal.vue +++ b/frontend/src/components/Modals/CallLogModal.vue @@ -124,7 +124,10 @@ const callBacks = { loading.value = false if (err.exc_type == 'MandatoryError') { const errorMessage = err.messages - .map((msg) => msg.split(': ')[2].trim()) + .map((msg) => { + let arr = msg.split(': ') + return arr[arr.length - 1].trim() + }) .join(', ') error.value = __('These fields are required: {0}', [errorMessage]) return diff --git a/frontend/src/components/Modals/ConvertToDealModal.vue b/frontend/src/components/Modals/ConvertToDealModal.vue new file mode 100644 index 00000000..427d688f --- /dev/null +++ b/frontend/src/components/Modals/ConvertToDealModal.vue @@ -0,0 +1,247 @@ + + diff --git a/frontend/src/data/document.js b/frontend/src/data/document.js index a77174cd..d5893552 100644 --- a/frontend/src/data/document.js +++ b/frontend/src/data/document.js @@ -26,7 +26,10 @@ export function useDocument(doctype, docname) { let errorMessage = __('Error updating document') if (err.exc_type == 'MandatoryError') { const fieldName = err.messages - .map((msg) => msg.split(': ')[2].trim()) + .map((msg) => { + let arr = msg.split(': ') + return arr[arr.length - 1].trim() + }) .join(', ') errorMessage = __('Mandatory field error: {0}', [fieldName]) } @@ -193,8 +196,7 @@ export function useDocument(doctype, docname) { async function triggerConvertToDeal() { const args = Array.from(arguments) const handler = async function () { - await (this.convertToDeal?.(...args) || - this.on_convert_to_deal?.(...args)) + await (this.convertToDeal?.(...args) || this.convert_to_deal?.(...args)) } await trigger(handler) } diff --git a/frontend/src/pages/Lead.vue b/frontend/src/pages/Lead.vue index 52811b41..1b830319 100644 --- a/frontend/src/pages/Lead.vue +++ b/frontend/src/pages/Lead.vue @@ -216,108 +216,11 @@ :errorTitle="errorTitle" :errorMessage="errorMessage" /> - - - - + :lead="lead.data" + /> (showConvertToDealModal.value = false), - ) - - let _deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', { - lead: lead.data.name, - deal, - existing_contact: existingContact.value, - existing_organization: existingOrganization.value, - }).catch((err) => { - toast.error(__('Error converting to deal: {0}', [err.messages?.[0]])) - }) - if (_deal) { - showConvertToDealModal.value = false - existingContactChecked.value = false - existingOrganizationChecked.value = false - existingContact.value = '' - existingOrganization.value = '' - updateOnboardingStep('convert_lead_to_deal', true, false, () => { - localStorage.setItem('firstDeal' + user, _deal) - }) - capture('convert_lead_to_deal') - router.push({ name: 'Deal', params: { dealId: _deal } }) - } -} - const activities = ref(null) function openEmailBox() { @@ -698,54 +531,6 @@ function openEmailBox() { nextTick(() => (activities.value.emailBox.show = true)) } -const deal = reactive({}) - -const dealStatuses = computed(() => { - let statuses = statusOptions('deal') - if (!deal.status) { - deal.status = statuses[0].value - } - return statuses -}) - -const dealTabs = createResource({ - url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout', - cache: ['RequiredFields', 'CRM Deal'], - params: { doctype: 'CRM Deal', type: 'Required Fields' }, - auto: true, - transform: (_tabs) => { - let hasFields = false - let parsedTabs = _tabs?.forEach((tab) => { - tab.sections?.forEach((section) => { - section.columns?.forEach((column) => { - column.fields?.forEach((field) => { - hasFields = true - if (field.fieldname == 'status') { - field.fieldtype = 'Select' - field.options = dealStatuses.value - field.prefix = getDealStatus(deal.status).color - } - - if (field.fieldtype === 'Table') { - deal[field.fieldname] = [] - } - }) - }) - }) - }) - return hasFields ? parsedTabs : [] - }, -}) - -function openQuickEntryModal() { - showQuickEntryModal.value = true - quickEntryProps.value = { - doctype: 'CRM Deal', - onlyRequired: true, - } - showConvertToDealModal.value = false -} - function reloadAssignees(data) { if (data?.hasOwnProperty('lead_owner')) { assignees.reload() diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index c337907e..375d5f51 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -85,7 +85,7 @@ export function prettyDate(date, mini = false) { let nowDatetime = dayjs().tz(localTimezone || systemTimezone) let diff = nowDatetime.diff(date, 'seconds') - let dayDiff = Math.floor(diff / 86400) + let dayDiff = diff / 86400 if (isNaN(dayDiff)) return '' @@ -93,15 +93,15 @@ export function prettyDate(date, mini = false) { // Return short format of time difference if (dayDiff < 0) { if (Math.abs(dayDiff) < 1) { - if (diff < 60) { + if (Math.abs(diff) < 60) { return __('now') - } else if (diff < 3600) { - return __('in {0} m', [Math.floor(diff / 60)]) - } else if (diff < 86400) { - return __('in {0} h', [Math.floor(diff / 3600)]) + } else if (Math.abs(diff) < 3600) { + return __('in {0} m', [Math.floor(Math.abs(diff) / 60)]) + } else if (Math.abs(diff) < 86400) { + return __('in {0} h', [Math.floor(Math.abs(diff) / 3600)]) } } - if (Math.abs(dayDiff) == 1) { + if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) { return __('tomorrow') } else if (Math.abs(dayDiff) < 7) { return __('in {0} d', [Math.abs(dayDiff)]) @@ -112,7 +112,7 @@ export function prettyDate(date, mini = false) { } else { return __('in {0} y', [Math.floor(Math.abs(dayDiff) / 365)]) } - } else if (dayDiff == 0) { + } else if (dayDiff >= 0 && dayDiff < 1) { if (diff < 60) { return __('now') } else if (diff < 3600) { @@ -135,19 +135,19 @@ export function prettyDate(date, mini = false) { // Return long format of time difference if (dayDiff < 0) { if (Math.abs(dayDiff) < 1) { - if (diff < 60) { + if (Math.abs(diff) < 60) { return __('just now') - } else if (diff < 120) { + } else if (Math.abs(diff) < 120) { return __('in 1 minute') - } else if (diff < 3600) { - return __('in {0} minutes', [Math.floor(diff / 60)]) - } else if (diff < 7200) { + } else if (Math.abs(diff) < 3600) { + return __('in {0} minutes', [Math.floor(Math.abs(diff) / 60)]) + } else if (Math.abs(diff) < 7200) { return __('in 1 hour') - } else if (diff < 86400) { - return __('in {0} hours', [Math.floor(diff / 3600)]) + } else if (Math.abs(diff) < 86400) { + return __('in {0} hours', [Math.floor(Math.abs(diff) / 3600)]) } } - if (Math.abs(dayDiff) == 1) { + if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) { return __('tomorrow') } else if (Math.abs(dayDiff) < 7) { return __('in {0} days', [Math.abs(dayDiff)]) @@ -160,7 +160,7 @@ export function prettyDate(date, mini = false) { } else { return __('in {0} years', [Math.floor(Math.abs(dayDiff) / 365)]) } - } else if (dayDiff == 0) { + } else if (dayDiff >= 0 && dayDiff < 1) { if (diff < 60) { return __('just now') } else if (diff < 120) {