diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json index bbef186d..6b7ce0fe 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.json +++ b/crm/fcrm/doctype/crm_deal/crm_deal.json @@ -14,6 +14,8 @@ "column_break_ijan", "status", "deal_owner", + "lost_reason", + "lost_notes", "section_break_jgpm", "probability", "deal_value", @@ -391,12 +393,25 @@ { "fieldname": "column_break_kpxa", "fieldtype": "Column Break" + }, + { + "fieldname": "lost_reason", + "fieldtype": "Link", + "label": "Lost Reason", + "mandatory_depends_on": "eval: doc.status == \"Lost\"", + "options": "CRM Lost Reason" + }, + { + "fieldname": "lost_notes", + "fieldtype": "Text", + "label": "Lost Notes", + "mandatory_depends_on": "eval: doc.lost_reason == \"Other\"" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-06-16 11:42:49.413483", + "modified": "2025-07-02 11:07:50.192089", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Deal", diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index 9afab159..6e056ffe 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -25,6 +25,7 @@ class CRMDeal(Document): if self.has_value_changed("status"): add_status_change_log(self) self.validate_forcasting_fields() + self.validate_lost_reason() def after_insert(self): if self.deal_owner: @@ -141,14 +142,32 @@ class CRMDeal(Document): if self.status == "Won" and not self.close_date: self.close_date = frappe.utils.nowdate() + def update_default_probability(self): + """ + Update the default probability based on the status. + """ + if not self.probability or self.probability == 0: + self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0 + def validate_forcasting_fields(self): self.update_close_date() + self.update_default_probability() 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) + def validate_lost_reason(self): + """ + Validate the lost reason if the status is set to "Lost". + """ + if self.status == "Lost": + if not self.lost_reason: + frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError) + elif self.lost_reason == "Other" and not self.lost_notes: + frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError) + @staticmethod def default_list_data(): columns = [ diff --git a/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json index 416ccd7e..87fb72f3 100644 --- a/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json +++ b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json @@ -27,9 +27,10 @@ "label": "Details" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-02 22:13:30.498404", + "modified": "2025-06-30 16:53:51.721752", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Lead Source", @@ -44,7 +45,7 @@ "print": 1, "read": 1, "report": 1, - "role": "Sales User", + "role": "System Manager", "share": 1, "write": 1 }, @@ -60,6 +61,15 @@ "share": 1, "write": 1 }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + }, { "email": 1, "export": 1, @@ -71,7 +81,8 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/crm/fcrm/doctype/crm_lost_reason/__init__.py b/crm/fcrm/doctype/crm_lost_reason/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js new file mode 100644 index 00000000..effd824a --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Lost Reason", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json new file mode 100644 index 00000000..ce774060 --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json @@ -0,0 +1,79 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:lost_reason", + "creation": "2025-06-30 16:51:31.082360", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "lost_reason", + "description" + ], + "fields": [ + { + "fieldname": "lost_reason", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Lost Reason", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-06-30 16:59:15.094049", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Lost Reason", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py new file mode 100644 index 00000000..b43a399c --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMLostReason(Document): + pass diff --git a/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py b/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py new file mode 100644 index 00000000..2f353477 --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMLostReason(UnitTestCase): + """ + Unit tests for CRMLostReason. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMLostReason(IntegrationTestCase): + """ + Integration tests for CRMLostReason. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 984d8e3a..63df320c 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -162,7 +162,7 @@ declare module 'vue' { ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] - LucideCalendar: typeof import('~icons/lucide/calendar')['default'] + LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default'] LucideInfo: typeof import('~icons/lucide/info')['default'] LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default'] LucidePlus: typeof import('~icons/lucide/plus')['default'] diff --git a/frontend/src/components/Activities/Activities.vue b/frontend/src/components/Activities/Activities.vue index 1c420e04..e13095aa 100644 --- a/frontend/src/components/Activities/Activities.vue +++ b/frontend/src/components/Activities/Activities.vue @@ -368,6 +368,7 @@ @@ -518,7 +519,7 @@ const props = defineProps({ }, }) -const emit = defineEmits(['afterSave']) +const emit = defineEmits(['beforeSave', 'afterSave']) const route = useRoute() diff --git a/frontend/src/components/Activities/DataFields.vue b/frontend/src/components/Activities/DataFields.vue index 88119f80..5b864ef1 100644 --- a/frontend/src/components/Activities/DataFields.vue +++ b/frontend/src/components/Activities/DataFields.vue @@ -66,7 +66,7 @@ import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue' import { usersStore } from '@/stores/users' import { useDocument } from '@/data/document' import { isMobileView } from '@/composables/settings' -import { ref, watch } from 'vue' +import { ref, watch, getCurrentInstance } from 'vue' const props = defineProps({ doctype: { @@ -79,10 +79,13 @@ const props = defineProps({ }, }) -const emit = defineEmits(['afterSave']) +const emit = defineEmits(['beforeSave', 'afterSave']) const { isManager } = usersStore() +const instance = getCurrentInstance() +const attrs = instance?.vnode?.props ?? {} + const showDataFieldsModal = ref(false) const { document } = useDocument(props.doctype, props.docname) @@ -107,9 +110,15 @@ function saveChanges() { return acc }, {}) - document.save.submit(null, { - onSuccess: () => emit('afterSave', changes), - }) + const hasListener = attrs['onBeforeSave'] !== undefined + + if (hasListener) { + emit('beforeSave', changes) + } else { + document.save.submit(null, { + onSuccess: () => emit('afterSave', changes), + }) + } } watch( diff --git a/frontend/src/components/Modals/DealModal.vue b/frontend/src/components/Modals/DealModal.vue index 7ccb128f..c9436e76 100644 --- a/frontend/src/components/Modals/DealModal.vue +++ b/frontend/src/components/Modals/DealModal.vue @@ -98,11 +98,7 @@ const show = defineModel() const router = useRouter() const error = ref(null) -const { - document: deal, - triggerOnChange, - triggerOnBeforeCreate, -} = useDocument('CRM Deal') +const { document: deal, triggerOnBeforeCreate } = useDocument('CRM Deal') const hasOrganizationSections = ref(true) const hasContactSections = ref(true) @@ -172,7 +168,7 @@ const tabs = createResource({ }) const dealStatuses = computed(() => { - let statuses = statusOptions('deal', null, [], triggerOnChange) + let statuses = statusOptions('deal') if (!deal.doc.status) { deal.doc.status = statuses[0].value } diff --git a/frontend/src/components/Modals/LeadModal.vue b/frontend/src/components/Modals/LeadModal.vue index 7c2dc728..9eb6ce50 100644 --- a/frontend/src/components/Modals/LeadModal.vue +++ b/frontend/src/components/Modals/LeadModal.vue @@ -74,14 +74,10 @@ const router = useRouter() const error = ref(null) const isLeadCreating = ref(false) -const { - document: lead, - triggerOnChange, - triggerOnBeforeCreate, -} = useDocument('CRM Lead') +const { document: lead, triggerOnBeforeCreate } = useDocument('CRM Lead') const leadStatuses = computed(() => { - let statuses = statusOptions('lead', null, [], triggerOnChange) + let statuses = statusOptions('lead') if (!lead.doc.status) { lead.doc.status = statuses?.[0]?.value } diff --git a/frontend/src/components/Modals/LostReasonModal.vue b/frontend/src/components/Modals/LostReasonModal.vue new file mode 100644 index 00000000..501733be --- /dev/null +++ b/frontend/src/components/Modals/LostReasonModal.vue @@ -0,0 +1,98 @@ + + diff --git a/frontend/src/components/SidePanelLayout.vue b/frontend/src/components/SidePanelLayout.vue index c8dcaaab..1bfe6b3a 100644 --- a/frontend/src/components/SidePanelLayout.vue +++ b/frontend/src/components/SidePanelLayout.vue @@ -400,7 +400,7 @@ import { getFormat, evaluateDependsOnValue } from '@/utils' import { flt } from '@/utils/numberFormat.js' import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui' import { useDocument } from '@/data/document' -import { ref, computed } from 'vue' +import { ref, computed, getCurrentInstance } from 'vue' const props = defineProps({ sections: { @@ -424,7 +424,7 @@ const props = defineProps({ }, }) -const emit = defineEmits(['afterFieldChange', 'reload']) +const emit = defineEmits(['beforeFieldChange', 'afterFieldChange', 'reload']) const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = getMeta(props.doctype) @@ -496,18 +496,23 @@ function parsedField(field) { return _field } +const instance = getCurrentInstance() +const attrs = instance?.vnode?.props ?? {} + async function fieldChange(value, df) { if (props.preview) return await triggerOnChange(df.fieldname, value) - document.save.submit(null, { - onSuccess: () => { - emit('afterFieldChange', { - [df.fieldname]: value, - }) - }, - }) + const hasListener = attrs['onBeforeFieldChange'] !== undefined + + if (hasListener) { + emit('beforeFieldChange', { [df.fieldname]: value }) + } else { + document.save.submit(null, { + onSuccess: () => emit('afterFieldChange', { [df.fieldname]: value }), + }) + } } function parsedSection(section, editButtonAdded) { diff --git a/frontend/src/pages/Deal.vue b/frontend/src/pages/Deal.vue index c21400f6..df0608c2 100644 --- a/frontend/src/pages/Deal.vue +++ b/frontend/src/pages/Deal.vue @@ -26,9 +26,10 @@ :options=" statusOptions( 'deal', - document, - deal.data._customStatuses, - triggerOnChange, + document.statuses?.length + ? document.statuses + : deal.data._customStatuses, + triggerStatusChange, ) " > @@ -60,6 +61,7 @@ v-model:reload="reload" v-model:tabIndex="tabIndex" v-model="deal" + @beforeSave="beforeStatusChange" @afterSave="reloadAssignees" /> @@ -147,6 +149,7 @@ doctype="CRM Deal" :docname="deal.data.name" @reload="sections.reload" + @beforeFieldChange="beforeStatusChange" @afterFieldChange="reloadAssignees" >