Merge pull request #1043 from frappe/main-hotfix
This commit is contained in:
commit
b0989566d9
1142
crm/api/dashboard.py
Normal file
1142
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -23,9 +23,6 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name)
|
||||
user.is_admin = user.name == "Administrator"
|
||||
|
||||
user.roles = frappe.get_roles(user.name)
|
||||
|
||||
user.role = ""
|
||||
@ -42,7 +39,7 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
|
||||
crm_users = []
|
||||
|
||||
|
||||
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
@ -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 Dashboard", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2025-07-14 12:19:49.725022",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"private",
|
||||
"column_break_exbw",
|
||||
"user",
|
||||
"section_break_hfza",
|
||||
"layout"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "column_break_exbw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hfza",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "[]",
|
||||
"fieldname": "layout",
|
||||
"fieldtype": "Code",
|
||||
"label": "Layout",
|
||||
"options": "JSON"
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Name",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "private",
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"mandatory_depends_on": "private",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "private",
|
||||
"fieldtype": "Check",
|
||||
"label": "Private"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-14 12:36:10.831351",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Dashboard",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMDashboard(Document):
|
||||
pass
|
||||
|
||||
|
||||
def default_manager_dashboard_layout():
|
||||
"""
|
||||
Returns the default layout for the CRM Manager Dashboard.
|
||||
"""
|
||||
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
|
||||
|
||||
|
||||
def create_default_manager_dashboard(force=False):
|
||||
"""
|
||||
Creates the default CRM Manager Dashboard if it does not exist.
|
||||
"""
|
||||
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
|
||||
doc = frappe.new_doc("CRM Dashboard")
|
||||
doc.title = "Manager Dashboard"
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.insert(ignore_permissions=True)
|
||||
elif force:
|
||||
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.layout
|
||||
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
@ -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 UnitTestCRMDashboard(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMDashboard.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMDashboard(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMDashboard.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -18,9 +18,11 @@
|
||||
"lost_notes",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"expected_deal_value",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
"closed_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
@ -37,6 +39,7 @@
|
||||
"column_break_xbyf",
|
||||
"territory",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"industry",
|
||||
"person_section",
|
||||
@ -93,11 +96,6 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "close_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Close Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_step",
|
||||
"fieldtype": "Data",
|
||||
@ -409,12 +407,35 @@
|
||||
"fieldtype": "Text",
|
||||
"label": "Lost Notes",
|
||||
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Expected Deal Value",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_closure_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Closure Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "closed_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Closed Date"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-05 12:25:05.927806",
|
||||
"modified": "2025-07-13 11:54:20.608489",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -10,6 +10,7 @@ from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
add_status_change_log,
|
||||
)
|
||||
from crm.utils import get_exchange_rate
|
||||
|
||||
|
||||
class CRMDeal(Document):
|
||||
@ -24,8 +25,11 @@ class CRMDeal(Document):
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
self.validate_forcasting_fields()
|
||||
self.validate_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
@ -162,12 +166,21 @@ class CRMDeal(Document):
|
||||
"""
|
||||
Validate the lost reason if the status is set to "Lost".
|
||||
"""
|
||||
if self.status == "Lost":
|
||||
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "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)
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"color",
|
||||
"type",
|
||||
"position",
|
||||
"probability"
|
||||
"column_break_ojiu",
|
||||
"probability",
|
||||
"color"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -39,12 +41,24 @@
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Probability"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ojiu",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-01 12:06:42.937440",
|
||||
"modified": "2025-07-11 16:03:28.077955",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
|
||||
@ -160,7 +160,7 @@ def add_forecasting_section(layout, doctype):
|
||||
"columns": [
|
||||
{
|
||||
"name": "column_" + str(random_string(4)),
|
||||
"fields": ["close_date", "probability", "deal_value"],
|
||||
"fields": ["expected_closure_date", "probability", "expected_deal_value"],
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"organization_name",
|
||||
"no_of_employees",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"organization_logo",
|
||||
"column_break_pnpp",
|
||||
@ -74,12 +75,18 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Address",
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
}
|
||||
],
|
||||
"image_field": "organization_logo",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-17 18:37:10.341062",
|
||||
"modified": "2025-07-15 11:40:12.175598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Organization",
|
||||
@ -111,7 +118,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,51 +4,65 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.utils import get_exchange_rate
|
||||
|
||||
|
||||
class CRMOrganization(Document):
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Organization',
|
||||
'type': 'Data',
|
||||
'key': 'organization_name',
|
||||
'width': '16rem',
|
||||
},
|
||||
{
|
||||
'label': 'Website',
|
||||
'type': 'Data',
|
||||
'key': 'website',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Industry',
|
||||
'type': 'Link',
|
||||
'key': 'industry',
|
||||
'options': 'CRM Industry',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Annual Revenue',
|
||||
'type': 'Currency',
|
||||
'key': 'annual_revenue',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"organization_name",
|
||||
"organization_logo",
|
||||
"website",
|
||||
"industry",
|
||||
"currency",
|
||||
"annual_revenue",
|
||||
"modified",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
def validate(self):
|
||||
self.update_exchange_rate()
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
"label": "Organization",
|
||||
"type": "Data",
|
||||
"key": "organization_name",
|
||||
"width": "16rem",
|
||||
},
|
||||
{
|
||||
"label": "Website",
|
||||
"type": "Data",
|
||||
"key": "website",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Industry",
|
||||
"type": "Link",
|
||||
"key": "industry",
|
||||
"options": "CRM Industry",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Annual Revenue",
|
||||
"type": "Currency",
|
||||
"key": "annual_revenue",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"organization_name",
|
||||
"organization_logo",
|
||||
"website",
|
||||
"industry",
|
||||
"currency",
|
||||
"annual_revenue",
|
||||
"modified",
|
||||
]
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
"column_break_mwmz",
|
||||
"duration",
|
||||
"last_status_change_log",
|
||||
"from_type",
|
||||
"to_type",
|
||||
"log_owner"
|
||||
],
|
||||
"fields": [
|
||||
@ -61,18 +63,31 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Owner",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "From Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "to_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "To Type"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-06 13:26:40.597277",
|
||||
"modified": "2025-07-13 12:37:41.278584",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Status Change Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from datetime import datetime
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
|
||||
class CRMStatusChangeLog(Document):
|
||||
pass
|
||||
|
||||
|
||||
def get_duration(from_date, to_date):
|
||||
if not isinstance(from_date, datetime):
|
||||
from_date = get_datetime(from_date)
|
||||
@ -18,28 +20,45 @@ def get_duration(from_date, to_date):
|
||||
duration = to_date - from_date
|
||||
return duration.total_seconds()
|
||||
|
||||
|
||||
def add_status_change_log(doc):
|
||||
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
||||
|
||||
if not doc.is_new():
|
||||
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
||||
previous_status_type = (
|
||||
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
|
||||
)
|
||||
if not doc.status_change_log and previous_status:
|
||||
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
|
||||
doc.append("status_change_log", {
|
||||
"from": previous_status,
|
||||
"to": "",
|
||||
"from_date": now_minus_one_minute,
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": previous_status,
|
||||
"from_type": previous_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": now_minus_one_minute,
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
},
|
||||
)
|
||||
last_status_change = doc.status_change_log[-1]
|
||||
last_status_change.to = doc.status
|
||||
last_status_change.to_type = to_status_type or ""
|
||||
last_status_change.to_date = datetime.now()
|
||||
last_status_change.log_owner = frappe.session.user
|
||||
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
||||
|
||||
doc.append("status_change_log", {
|
||||
"from": doc.status,
|
||||
"to": "",
|
||||
"from_date": datetime.now(),
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": doc.status,
|
||||
"from_type": to_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": datetime.now(),
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
},
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"currency",
|
||||
"branding_tab",
|
||||
"brand_name",
|
||||
"brand_logo",
|
||||
@ -60,16 +61,23 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"fieldname": "enable_forecasting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Forecasting"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-01 13:20:48.757603",
|
||||
"modified": "2025-07-13 11:58:34.857638",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
|
||||
@ -17,6 +17,7 @@ class FCRMSettings(Document):
|
||||
def validate(self):
|
||||
self.do_not_allow_to_delete_if_standard()
|
||||
self.setup_forecasting()
|
||||
self.make_currency_read_only()
|
||||
|
||||
def do_not_allow_to_delete_if_standard(self):
|
||||
if not self.has_value_changed("dropdown_items"):
|
||||
@ -37,29 +38,39 @@ class FCRMSettings(Document):
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
)
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"deal_value",
|
||||
"expected_deal_value",
|
||||
)
|
||||
else:
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"deal_value",
|
||||
"expected_deal_value",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
|
||||
def make_currency_read_only(self):
|
||||
if self.currency and self.has_value_changed("currency"):
|
||||
make_property_setter(
|
||||
"FCRM Settings",
|
||||
"currency",
|
||||
"read_only",
|
||||
1,
|
||||
"Check",
|
||||
)
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
|
||||
@ -4,6 +4,7 @@ import click
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
|
||||
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||
|
||||
|
||||
@ -23,6 +24,7 @@ def after_install(force=False):
|
||||
add_default_lost_reasons()
|
||||
add_standard_dropdown_items()
|
||||
add_default_scripts()
|
||||
create_default_manager_dashboard(force)
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -69,36 +71,43 @@ def add_default_deal_statuses():
|
||||
statuses = {
|
||||
"Qualification": {
|
||||
"color": "gray",
|
||||
"type": "Open",
|
||||
"probability": 10,
|
||||
"position": 1,
|
||||
},
|
||||
"Demo/Making": {
|
||||
"color": "orange",
|
||||
"type": "Ongoing",
|
||||
"probability": 25,
|
||||
"position": 2,
|
||||
},
|
||||
"Proposal/Quotation": {
|
||||
"color": "blue",
|
||||
"type": "Ongoing",
|
||||
"probability": 50,
|
||||
"position": 3,
|
||||
},
|
||||
"Negotiation": {
|
||||
"color": "yellow",
|
||||
"type": "Ongoing",
|
||||
"probability": 70,
|
||||
"position": 4,
|
||||
},
|
||||
"Ready to Close": {
|
||||
"color": "purple",
|
||||
"type": "Ongoing",
|
||||
"probability": 90,
|
||||
"position": 5,
|
||||
},
|
||||
"Won": {
|
||||
"color": "green",
|
||||
"type": "Won",
|
||||
"probability": 100,
|
||||
"position": 6,
|
||||
},
|
||||
"Lost": {
|
||||
"color": "red",
|
||||
"type": "Lost",
|
||||
"probability": 0,
|
||||
"position": 7,
|
||||
},
|
||||
@ -111,6 +120,7 @@ def add_default_deal_statuses():
|
||||
doc = frappe.new_doc("CRM Deal Status")
|
||||
doc.deal_status = status
|
||||
doc.color = statuses[status]["color"]
|
||||
doc.type = statuses[status]["type"]
|
||||
doc.probability = statuses[status]["probability"]
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
|
||||
@ -242,19 +242,18 @@ def get_call_log_status(call_payload, direction="inbound"):
|
||||
elif status == "failed":
|
||||
return "Failed"
|
||||
|
||||
status = call_payload.get("DialCallStatus")
|
||||
call_type = call_payload.get("CallType")
|
||||
dial_call_status = call_payload.get("DialCallStatus")
|
||||
status = call_payload.get("DialCallStatus") or call_payload.get("Status")
|
||||
|
||||
if call_type == "incomplete" and dial_call_status == "no-answer":
|
||||
if call_type == "incomplete" and status == "no-answer":
|
||||
status = "No Answer"
|
||||
elif call_type == "client-hangup" and dial_call_status == "canceled":
|
||||
elif call_type == "client-hangup" and status == "canceled":
|
||||
status = "Canceled"
|
||||
elif call_type == "incomplete" and dial_call_status == "failed":
|
||||
elif call_type == "incomplete" and status == "failed":
|
||||
status = "Failed"
|
||||
elif call_type == "completed":
|
||||
status = "Completed"
|
||||
elif dial_call_status == "busy":
|
||||
elif status == "busy":
|
||||
status = "Ringing"
|
||||
|
||||
return status
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,4 +13,5 @@ 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
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
crm.patches.v1_0.update_deal_status_type
|
||||
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
@ -0,0 +1,44 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "type", "deal_status"])
|
||||
|
||||
openStatuses = ["New", "Open", "Unassigned", "Qualification"]
|
||||
ongoingStatuses = [
|
||||
"Demo/Making",
|
||||
"Proposal/Quotation",
|
||||
"Negotiation",
|
||||
"Ready to Close",
|
||||
"Demo Scheduled",
|
||||
"Follow Up",
|
||||
]
|
||||
onHoldStatuses = ["On Hold", "Paused", "Stalled", "Awaiting Reply"]
|
||||
wonStatuses = ["Won", "Closed Won", "Successful", "Completed"]
|
||||
lostStatuses = [
|
||||
"Lost",
|
||||
"Closed",
|
||||
"Closed Lost",
|
||||
"Junk",
|
||||
"Unqualified",
|
||||
"Disqualified",
|
||||
"Cancelled",
|
||||
"No Response",
|
||||
]
|
||||
|
||||
for status in deal_statuses:
|
||||
if not status.type or status.type is None or status.type == "Open":
|
||||
if status.deal_status in openStatuses:
|
||||
type = "Open"
|
||||
elif status.deal_status in ongoingStatuses:
|
||||
type = "Ongoing"
|
||||
elif status.deal_status in onHoldStatuses:
|
||||
type = "On Hold"
|
||||
elif status.deal_status in wonStatuses:
|
||||
type = "Won"
|
||||
elif status.deal_status in lostStatuses:
|
||||
type = "Lost"
|
||||
else:
|
||||
type = "Ongoing"
|
||||
|
||||
frappe.db.set_value("CRM Deal Status", status.name, "type", type)
|
||||
@ -1,10 +1,14 @@
|
||||
from frappe import frappe
|
||||
import functools
|
||||
|
||||
import frappe
|
||||
import phonenumbers
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
from frappe.utils import floor
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers import PhoneNumberFormat as PNF
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
|
||||
|
||||
def parse_phone_number(phone_number, default_country="IN"):
|
||||
@ -97,6 +101,7 @@ def seconds_to_duration(seconds):
|
||||
else:
|
||||
return "0s"
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
|
||||
def get_linked_docs(doc, method="Delete"):
|
||||
from frappe.model.rename_doc import get_link_fields
|
||||
@ -161,6 +166,7 @@ def get_linked_docs(doc, method="Delete"):
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
|
||||
def get_dynamic_linked_docs(doc, method="Delete"):
|
||||
docs = []
|
||||
@ -222,3 +228,59 @@ def get_dynamic_linked_docs(doc, method="Delete"):
|
||||
}
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
def is_admin(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an admin
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an admin
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return user == "Administrator"
|
||||
|
||||
|
||||
def is_sales_user(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an agent
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an agent
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return is_admin() or "Sales Manager" in frappe.get_roles(user) or "Sales User" in frappe.get_roles(user)
|
||||
|
||||
|
||||
def sales_user_only(fn):
|
||||
"""Decorator to validate if user is an agent."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_sales_user():
|
||||
frappe.throw(
|
||||
msg=_("You are not permitted to access this resource."),
|
||||
title=_("Not Allowed"),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_exchange_rate(from_currency, to_currency, date=None):
|
||||
if not date:
|
||||
date = "latest"
|
||||
|
||||
url = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
||||
|
||||
response = requests.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
rate = data["rates"].get(to_currency)
|
||||
return rate
|
||||
else:
|
||||
frappe.throw(_("Failed to fetch historical exchange rate from external API. Please try again later."))
|
||||
return None
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
|
||||
Subproject commit b295b54aaa3a2a22f455df819d87433dc6b9ff6a
|
||||
20
frontend/components.d.ts
vendored
20
frontend/components.d.ts
vendored
@ -12,6 +12,7 @@ declare module 'vue' {
|
||||
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
||||
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
||||
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
||||
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
|
||||
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
|
||||
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
|
||||
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
|
||||
@ -31,6 +32,7 @@ declare module 'vue' {
|
||||
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
|
||||
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
|
||||
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
|
||||
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
|
||||
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
|
||||
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
||||
@ -61,7 +63,9 @@ declare module 'vue' {
|
||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
||||
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
|
||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
|
||||
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
||||
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
||||
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
||||
@ -98,11 +102,9 @@ declare module 'vue' {
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||
@ -127,7 +129,8 @@ declare module 'vue' {
|
||||
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Settings/General/GeneralSettings.vue')['default']
|
||||
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.vue')['default']
|
||||
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||
@ -138,6 +141,7 @@ declare module 'vue' {
|
||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
|
||||
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
|
||||
HomeActions: typeof import('./src/components/Settings/General/HomeActions.vue')['default']
|
||||
Icon: typeof import('./src/components/Icon.vue')['default']
|
||||
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
|
||||
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
|
||||
@ -163,11 +167,10 @@ declare module 'vue' {
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['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']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
LucideX: typeof import('~icons/lucide/x')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
LucidePenLine: typeof import('~icons/lucide/pen-line')['default']
|
||||
LucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -201,7 +204,6 @@ declare module 'vue' {
|
||||
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
||||
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
||||
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
||||
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
|
||||
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
||||
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
||||
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.166",
|
||||
"frappe-ui": "^0.1.171",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:disabled="attrs.disabled"
|
||||
:placement="attrs.placement"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
|
||||
165
frontend/src/components/Dashboard/AddChartModal.vue
Normal file
165
frontend/src/components/Dashboard/AddChartModal.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ title: __('Add chart') }"
|
||||
@close="show = false"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
v-model="chartType"
|
||||
type="select"
|
||||
:label="__('Chart Type')"
|
||||
:options="chartTypes"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'number_chart'"
|
||||
v-model="numberChart"
|
||||
type="select"
|
||||
:label="__('Number chart')"
|
||||
:options="numberCharts"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'axis_chart'"
|
||||
v-model="axisChart"
|
||||
type="select"
|
||||
:label="__('Axis chart')"
|
||||
:options="axisCharts"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'donut_chart'"
|
||||
v-model="donutChart"
|
||||
type="select"
|
||||
:label="__('Donut chart')"
|
||||
:options="donutCharts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" :label="__('Cancel')" @click="show = false" />
|
||||
<Button variant="solid" :label="__('Add')" @click="addChart" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getRandom } from '@/utils'
|
||||
import { createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import { ref, reactive, inject } from 'vue'
|
||||
|
||||
const show = defineModel({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
|
||||
const items = defineModel('items', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const fromDate = inject('fromDate', ref(''))
|
||||
const toDate = inject('toDate', ref(''))
|
||||
const filters = inject('filters', reactive({ period: '', user: '' }))
|
||||
|
||||
const chartType = ref('spacer')
|
||||
const chartTypes = [
|
||||
{ label: __('Spacer'), value: 'spacer' },
|
||||
{ label: __('Number chart'), value: 'number_chart' },
|
||||
{ label: __('Axis chart'), value: 'axis_chart' },
|
||||
{ label: __('Donut chart'), value: 'donut_chart' },
|
||||
]
|
||||
|
||||
const numberChart = ref('')
|
||||
const numberCharts = [
|
||||
{ label: __('Total leads'), value: 'total_leads' },
|
||||
{ label: __('Ongoing deals'), value: 'ongoing_deals' },
|
||||
{ label: __('Avg ongoing deal value'), value: 'average_ongoing_deal_value' },
|
||||
{ label: __('Won deals'), value: 'won_deals' },
|
||||
{ label: __('Avg won deal value'), value: 'average_won_deal_value' },
|
||||
{ label: __('Avg deal value'), value: 'average_deal_value' },
|
||||
{
|
||||
label: __('Avg time to close a lead'),
|
||||
value: 'average_time_to_close_a_lead',
|
||||
},
|
||||
{
|
||||
label: __('Avg time to close a deal'),
|
||||
value: 'average_time_to_close_a_deal',
|
||||
},
|
||||
]
|
||||
|
||||
const axisChart = ref('sales_trend')
|
||||
const axisCharts = [
|
||||
{ label: __('Sales trend'), value: 'sales_trend' },
|
||||
{ label: __('Forecasted revenue'), value: 'forecasted_revenue' },
|
||||
{ label: __('Funnel conversion'), value: 'funnel_conversion' },
|
||||
{ label: __('Deals by ongoing & won stage'), value: 'deals_by_stage_axis' },
|
||||
{ label: __('Lost deal reasons'), value: 'lost_deal_reasons' },
|
||||
{ label: __('Deals by territory'), value: 'deals_by_territory' },
|
||||
{ label: __('Deals by salesperson'), value: 'deals_by_salesperson' },
|
||||
]
|
||||
|
||||
const donutChart = ref('deals_by_stage_donut')
|
||||
const donutCharts = [
|
||||
{ label: __('Deals by stage'), value: 'deals_by_stage_donut' },
|
||||
{ label: __('Leads by source'), value: 'leads_by_source' },
|
||||
{ label: __('Deals by source'), value: 'deals_by_source' },
|
||||
]
|
||||
|
||||
async function addChart() {
|
||||
show.value = false
|
||||
if (chartType.value == 'spacer') {
|
||||
items.value.push({
|
||||
name: 'spacer',
|
||||
type: 'spacer',
|
||||
layout: { x: 0, y: 0, w: 4, h: 2, i: 'spacer_' + getRandom(4) },
|
||||
})
|
||||
} else {
|
||||
await getChart(chartType.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function getChart(type: string) {
|
||||
let name =
|
||||
type == 'number_chart'
|
||||
? numberChart.value
|
||||
: type == 'axis_chart'
|
||||
? axisChart.value
|
||||
: donutChart.value
|
||||
|
||||
await createResource({
|
||||
url: 'crm.api.dashboard.get_chart',
|
||||
params: {
|
||||
name,
|
||||
type,
|
||||
from_date: fromDate.value,
|
||||
to_date: toDate.value,
|
||||
user: filters.user,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data = {}) => {
|
||||
let width = 4
|
||||
let height = 2
|
||||
|
||||
if (['axis_chart', 'donut_chart'].includes(type)) {
|
||||
width = 10
|
||||
height = 7
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
name,
|
||||
type,
|
||||
layout: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h: height,
|
||||
i: name + '_' + getRandom(4),
|
||||
},
|
||||
data: data,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<GridLayout
|
||||
v-if="items.length > 0"
|
||||
class="h-fit w-full"
|
||||
:class="[editing ? 'mb-[20rem] !select-none' : '']"
|
||||
:cols="20"
|
||||
:rowHeight="42"
|
||||
:disabled="!editing"
|
||||
:modelValue="items.map((item) => item.layout)"
|
||||
@update:modelValue="
|
||||
(newLayout) => {
|
||||
items.forEach((item, idx) => {
|
||||
item.layout = newLayout[idx]
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ index }">
|
||||
<div class="group relative flex h-full w-full p-2 text-ink-gray-8">
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
:class="
|
||||
editing
|
||||
? 'pointer-events-none [&>div:first-child]:rounded [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<DashboardItem
|
||||
:index="index"
|
||||
:item="items[index]"
|
||||
:editing="editing"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="editing"
|
||||
class="flex absolute right-0 top-0 bg-surface-gray-6 rounded cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
class="rounded p-1 hover:bg-surface-gray-5"
|
||||
@click="items.splice(index, 1)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="size-3 text-ink-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GridLayout>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { GridLayout } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const items = defineModel()
|
||||
</script>
|
||||
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div
|
||||
v-if="item.type == 'number_chart'"
|
||||
class="flex h-full w-full rounded shadow overflow-hidden cursor-pointer"
|
||||
>
|
||||
<Tooltip :text="__(item.data.tooltip)">
|
||||
<NumberChart v-if="item.data" :key="index" :config="item.data" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'spacer'"
|
||||
class="rounded bg-surface-white h-full overflow-hidden text-ink-gray-5 flex items-center justify-center"
|
||||
:class="editing ? 'border border-dashed border-outline-gray-2' : ''"
|
||||
>
|
||||
{{ editing ? __('Spacer') : '' }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'axis_chart'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow"
|
||||
>
|
||||
<AxisChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'donut_chart'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow overflow-hidden"
|
||||
>
|
||||
<DonutChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { AxisChart, DonutChart, NumberChart, Tooltip } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -140,6 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import CRMLogo from '@/components/Icons/CRMLogo.vue'
|
||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||
import ConvertIcon from '@/components/Icons/ConvertIcon.vue'
|
||||
@ -196,51 +197,62 @@ const isSidebarCollapsed = useStorage('isSidebarCollapsed', false)
|
||||
const isFCSite = ref(window.is_fc_site)
|
||||
const isDemoSite = ref(window.is_demo_site)
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: 'Leads',
|
||||
icon: LeadsIcon,
|
||||
to: 'Leads',
|
||||
},
|
||||
{
|
||||
label: 'Deals',
|
||||
icon: DealsIcon,
|
||||
to: 'Deals',
|
||||
},
|
||||
{
|
||||
label: 'Contacts',
|
||||
icon: ContactsIcon,
|
||||
to: 'Contacts',
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
icon: OrganizationsIcon,
|
||||
to: 'Organizations',
|
||||
},
|
||||
{
|
||||
label: 'Notes',
|
||||
icon: NoteIcon,
|
||||
to: 'Notes',
|
||||
},
|
||||
{
|
||||
label: 'Tasks',
|
||||
icon: TaskIcon,
|
||||
to: 'Tasks',
|
||||
},
|
||||
{
|
||||
label: 'Call Logs',
|
||||
icon: PhoneIcon,
|
||||
to: 'Call Logs',
|
||||
},
|
||||
]
|
||||
|
||||
const allViews = computed(() => {
|
||||
const links = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: LucideLayoutDashboard,
|
||||
to: 'Dashboard',
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: 'Leads',
|
||||
icon: LeadsIcon,
|
||||
to: 'Leads',
|
||||
},
|
||||
{
|
||||
label: 'Deals',
|
||||
icon: DealsIcon,
|
||||
to: 'Deals',
|
||||
},
|
||||
{
|
||||
label: 'Contacts',
|
||||
icon: ContactsIcon,
|
||||
to: 'Contacts',
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
icon: OrganizationsIcon,
|
||||
to: 'Organizations',
|
||||
},
|
||||
{
|
||||
label: 'Notes',
|
||||
icon: NoteIcon,
|
||||
to: 'Notes',
|
||||
},
|
||||
{
|
||||
label: 'Tasks',
|
||||
icon: TaskIcon,
|
||||
to: 'Tasks',
|
||||
},
|
||||
{
|
||||
label: 'Call Logs',
|
||||
icon: PhoneIcon,
|
||||
to: 'Call Logs',
|
||||
},
|
||||
]
|
||||
|
||||
let _views = [
|
||||
{
|
||||
name: 'All Views',
|
||||
hideLabel: true,
|
||||
opened: true,
|
||||
views: links,
|
||||
views: links.filter((link) => {
|
||||
if (link.condition) {
|
||||
return link.condition()
|
||||
}
|
||||
return true
|
||||
}),
|
||||
},
|
||||
]
|
||||
if (getPublicViews().length) {
|
||||
|
||||
@ -37,7 +37,13 @@
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import { capture } from '@/telemetry'
|
||||
import { FormControl, call, createResource, TextEditor, DatePicker } from 'frappe-ui'
|
||||
import {
|
||||
FormControl,
|
||||
call,
|
||||
createResource,
|
||||
TextEditor,
|
||||
DatePicker,
|
||||
} from 'frappe-ui'
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
|
||||
const typeCheck = ['Check']
|
||||
@ -70,7 +76,7 @@ const fields = createResource({
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.filter((f) => f.hidden == 0 && f.read_only == 0)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@ -82,8 +88,8 @@ const recordCount = computed(() => props.selectedValues?.size || 0)
|
||||
|
||||
const field = ref({
|
||||
label: '',
|
||||
type: '',
|
||||
value: '',
|
||||
fieldtype: '',
|
||||
fieldname: '',
|
||||
options: '',
|
||||
})
|
||||
|
||||
@ -92,7 +98,7 @@ const loading = ref(false)
|
||||
|
||||
function updateValues() {
|
||||
let fieldVal = newValue.value
|
||||
if (field.value.type == 'Check') {
|
||||
if (field.value.fieldtype == 'Check') {
|
||||
fieldVal = fieldVal == 'Yes' ? 1 : 0
|
||||
}
|
||||
loading.value = true
|
||||
@ -103,14 +109,14 @@ function updateValues() {
|
||||
docnames: Array.from(props.selectedValues),
|
||||
action: 'update',
|
||||
data: {
|
||||
[field.value.value]: fieldVal || null,
|
||||
[field.value.fieldname]: fieldVal || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
).then(() => {
|
||||
field.value = {
|
||||
label: '',
|
||||
type: '',
|
||||
value: '',
|
||||
fieldtype: '',
|
||||
fieldname: '',
|
||||
options: '',
|
||||
}
|
||||
newValue.value = ''
|
||||
@ -137,9 +143,10 @@ function getSelectOptions(options) {
|
||||
}
|
||||
|
||||
function getValueComponent(f) {
|
||||
const { type, options } = f
|
||||
if (typeSelect.includes(type) || typeCheck.includes(type)) {
|
||||
const _options = type == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
||||
const { fieldtype, options } = f
|
||||
if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
|
||||
const _options =
|
||||
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
||||
return h(FormControl, {
|
||||
type: 'select',
|
||||
options: _options.map((o) => ({
|
||||
@ -148,16 +155,16 @@ function getValueComponent(f) {
|
||||
})),
|
||||
modelValue: newValue.value,
|
||||
})
|
||||
} else if (typeLink.includes(type)) {
|
||||
if (type == 'Dynamic Link') {
|
||||
} else if (typeLink.includes(fieldtype)) {
|
||||
if (fieldtype == 'Dynamic Link') {
|
||||
return h(FormControl, { type: 'text' })
|
||||
}
|
||||
return h(Link, { class: 'form-control', doctype: options })
|
||||
} else if (typeNumber.includes(type)) {
|
||||
} else if (typeNumber.includes(fieldtype)) {
|
||||
return h(FormControl, { type: 'number' })
|
||||
} else if (typeDate.includes(type)) {
|
||||
} else if (typeDate.includes(fieldtype)) {
|
||||
return h(DatePicker)
|
||||
} else if (typeEditor.includes(type)) {
|
||||
} else if (typeEditor.includes(fieldtype)) {
|
||||
return h(TextEditor, {
|
||||
variant: 'outline',
|
||||
editorClass:
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between p-1 py-3 border-b border-outline-gray-modals cursor-pointer"
|
||||
class="flex items-center justify-between px-2 py-3 border-outline-gray-modals cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
>
|
||||
<!-- avatar and name -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-ink-gray-8">
|
||||
<div class="text-p-base text-ink-gray-8">
|
||||
{{ emailAccount.email_account_name }}
|
||||
</p>
|
||||
<div class="text-sm text-ink-gray-4">{{ emailAccount.email_id }}</div>
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5">{{ emailAccount.email_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -30,11 +30,18 @@
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
class="mt-4"
|
||||
>
|
||||
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
|
||||
<div
|
||||
v-for="(emailAccount, i) in emailAccounts.data"
|
||||
:key="emailAccount.name"
|
||||
>
|
||||
<EmailAccountCard
|
||||
:emailAccount="emailAccount"
|
||||
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||
/>
|
||||
<div
|
||||
v-if="emailAccounts.data.length !== i + 1"
|
||||
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
|
||||
@ -92,10 +92,10 @@
|
||||
@click="() => emit('updateStep', 'edit-template', { ...template })"
|
||||
>
|
||||
<div class="flex flex-col w-4/6 pr-5">
|
||||
<div class="text-base font-medium text-ink-gray-7 truncate">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ template.name }}
|
||||
</div>
|
||||
<div class="text-p-base text-ink-gray-5 truncate">
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{ template.subject }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,31 +1,37 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('General') }}
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure general settings for your CRM') }}
|
||||
</p>
|
||||
<div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex px-2 justify-between">
|
||||
<div class="flex items-center gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__('Brand settings')"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'general-settings')"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!settings.isDirty"
|
||||
:loading="settings.loading"
|
||||
@click="updateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.doc" class="flex-1 flex flex-col gap-8 overflow-y-auto">
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col p-2 gap-4 overflow-y-auto">
|
||||
<div class="flex w-full">
|
||||
<FormControl
|
||||
type="text"
|
||||
@ -36,7 +42,6 @@
|
||||
</div>
|
||||
|
||||
<!-- logo -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Logo') }}
|
||||
@ -71,7 +76,6 @@
|
||||
</div>
|
||||
|
||||
<!-- favicon -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Favicon') }}
|
||||
@ -104,33 +108,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home actions -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Home actions') }}
|
||||
</span>
|
||||
<div class="flex flex-1">
|
||||
<Grid
|
||||
v-model="settings.doc.dropdown_items"
|
||||
doctype="CRM Dropdown Item"
|
||||
parentDoctype="FCRM Settings"
|
||||
parentFieldname="dropdown_items"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorMessage :message="settings.save.error" />
|
||||
<div v-if="errorMessage">
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ImageUploader from '@/components/Controls/ImageUploader.vue'
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { FormControl, Badge, ErrorMessage } from 'frappe-ui'
|
||||
import { FormControl, ErrorMessage } from 'frappe-ui'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { showSettings } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { _settings: settings, setupBrand } = getSettings()
|
||||
|
||||
@ -142,4 +131,7 @@ function updateSettings() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
</script>
|
||||
160
frontend/src/components/Settings/General/GeneralSettings.vue
Normal file
160
frontend/src/components/Settings/General/GeneralSettings.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('General') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure general settings for your CRM') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-y-auto">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="toggleForecasting()"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Enable forecasting') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{
|
||||
__(
|
||||
'Makes "Close Date" and "Deal Value" mandatory for deal value forecasting',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="settings.doc.enable_forecasting"
|
||||
@click.stop="toggleForecasting(settings.doc.enable_forecasting)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-2 border-outline-gray-modals" />
|
||||
<div
|
||||
class="flex items-center justify-between gap-8 p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Currency') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
'CRM currency for all monetary values. Once set, cannot be edited.',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="settings.doc.currency" class="text-base text-ink-gray-8">
|
||||
{{ settings.doc.currency }}
|
||||
</div>
|
||||
<Link
|
||||
v-else
|
||||
class="form-control flex-1 truncate w-40"
|
||||
:value="settings.doc.currency"
|
||||
doctype="Currency"
|
||||
@change="(v) => setCurrency(v)"
|
||||
:placeholder="__('Select currency')"
|
||||
placement="bottom-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-2 border-outline-gray-modals" />
|
||||
<template v-for="(setting, i) in settingsList" :key="setting.name">
|
||||
<li
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="() => emit('updateStep', setting.name)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __(setting.label) }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{ __(setting.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FeatherIcon name="chevron-right" class="text-ink-gray-7 size-4" />
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="settingsList.length !== i + 1"
|
||||
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { Switch, toast } from 'frappe-ui'
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const { _settings: settings } = getSettings()
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const settingsList = [
|
||||
{
|
||||
name: 'brand-settings',
|
||||
label: 'Brand settings',
|
||||
description: 'Configure your brand name, logo and favicon',
|
||||
},
|
||||
{
|
||||
name: 'home-actions',
|
||||
label: 'Home actions',
|
||||
description: 'Configure actions that appear on the home dropdown',
|
||||
},
|
||||
]
|
||||
|
||||
function toggleForecasting(value) {
|
||||
settings.doc.enable_forecasting =
|
||||
value !== undefined ? value : !settings.doc.enable_forecasting
|
||||
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
settings.doc.enable_forecasting
|
||||
? __('Forecasting enabled successfully')
|
||||
: __('Forecasting disabled successfully'),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setCurrency(value) {
|
||||
$dialog({
|
||||
title: __('Set currency'),
|
||||
message: __(
|
||||
'Are you sure you want to set the currency as {0}? This cannot be changed later.',
|
||||
[value],
|
||||
),
|
||||
variant: 'solid',
|
||||
theme: 'blue',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
settings.doc.currency = value
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(__('Currency set as {0} successfully', [value]))
|
||||
close()
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<component :is="getComponent(step)" :data="data" @updateStep="updateStep" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GeneralSettings from './GeneralSettings.vue'
|
||||
import BrandSettings from './BrandSettings.vue'
|
||||
import HomeActions from './HomeActions.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const step = ref('general-settings')
|
||||
const data = ref(null)
|
||||
|
||||
function updateStep(newStep, _data) {
|
||||
step.value = newStep
|
||||
data.value = _data
|
||||
}
|
||||
|
||||
function getComponent(step) {
|
||||
switch (step) {
|
||||
case 'general-settings':
|
||||
return GeneralSettings
|
||||
case 'brand-settings':
|
||||
return BrandSettings
|
||||
case 'home-actions':
|
||||
return HomeActions
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
frontend/src/components/Settings/General/HomeActions.vue
Normal file
60
frontend/src/components/Settings/General/HomeActions.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__('Home actions')"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'general-settings')"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!settings.isDirty"
|
||||
:loading="settings.loading"
|
||||
@click="updateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<Grid
|
||||
v-model="settings.doc.dropdown_items"
|
||||
doctype="CRM Dropdown Item"
|
||||
parentDoctype="FCRM Settings"
|
||||
parentFieldname="dropdown_items"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="errorMessage">
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { ErrorMessage } from 'frappe-ui'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { showSettings } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { _settings: settings } = getSettings()
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
|
||||
function updateSettings() {
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
showSettings.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -47,7 +47,7 @@ import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import Users from '@/components/Settings/Users.vue'
|
||||
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
||||
import GeneralSettingsPage from '@/components/Settings/General/GeneralSettingsPage.vue'
|
||||
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
@ -65,7 +65,7 @@ import {
|
||||
import { Dialog, Avatar } from 'frappe-ui'
|
||||
import { ref, markRaw, computed, watch, h } from 'vue'
|
||||
|
||||
const { isManager, isAgent, getUser } = usersStore()
|
||||
const { isManager, isTelephonyAgent, getUser } = usersStore()
|
||||
|
||||
const user = computed(() => getUser() || {})
|
||||
|
||||
@ -88,7 +88,7 @@ const tabs = computed(() => {
|
||||
{
|
||||
label: __('General'),
|
||||
icon: 'settings',
|
||||
component: markRaw(GeneralSettings),
|
||||
component: markRaw(GeneralSettingsPage),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
@ -123,7 +123,7 @@ const tabs = computed(() => {
|
||||
label: __('Telephony'),
|
||||
icon: PhoneIcon,
|
||||
component: markRaw(TelephonySettings),
|
||||
condition: () => isManager() || isAgent(),
|
||||
condition: () => isManager() || isTelephonyAgent(),
|
||||
},
|
||||
{
|
||||
label: __('WhatsApp'),
|
||||
@ -138,7 +138,7 @@ const tabs = computed(() => {
|
||||
condition: () => isManager(),
|
||||
},
|
||||
],
|
||||
condition: () => isManager() || isAgent(),
|
||||
condition: () => isManager() || isTelephonyAgent(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ import { toast } from 'frappe-ui'
|
||||
import { getRandom } from '@/utils'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const { isManager, isAgent } = usersStore()
|
||||
const { isManager, isTelephonyAgent } = usersStore()
|
||||
|
||||
const twilioFields = createResource({
|
||||
url: 'crm.api.doc.get_fields',
|
||||
@ -283,7 +283,7 @@ async function updateMedium() {
|
||||
const error = ref('')
|
||||
|
||||
function validateIfDefaultMediumIsEnabled() {
|
||||
if (isAgent() && !isManager()) return true
|
||||
if (isTelephonyAgent() && !isManager()) return true
|
||||
|
||||
if (defaultCallingMedium.value === 'Twilio' && !twilio.doc.enabled) {
|
||||
error.value = __('Twilio is not enabled')
|
||||
|
||||
@ -98,11 +98,11 @@
|
||||
:label="user.full_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 ml-3">
|
||||
<div class="flex items-center text-base text-ink-gray-8 h-4">
|
||||
<div class="flex flex-col ml-3">
|
||||
<div class="flex items-center text-p-base text-ink-gray-8">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<div class="text-base text-ink-gray-5">
|
||||
<div class="text-p-sm text-ink-gray-5">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<Popover class="w-full" v-model:show="showOptions" :placement="placement">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot
|
||||
name="target"
|
||||
@ -14,9 +14,9 @@
|
||||
>
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="relative flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
|
||||
class="relative flex h-7 w-full items-center justify-between gap-2 rounded px-2 py-1 transition-colors"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
@click="() => !disabled && togglePopover()"
|
||||
>
|
||||
<div
|
||||
v-if="selectedValue"
|
||||
@ -34,6 +34,7 @@
|
||||
{{ placeholder || '' }}
|
||||
</div>
|
||||
<FeatherIcon
|
||||
v-if="!disabled"
|
||||
name="chevron-down"
|
||||
class="absolute h-4 w-4 text-ink-gray-5 right-2"
|
||||
aria-hidden="true"
|
||||
@ -142,7 +143,7 @@ import {
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover, Button, FeatherIcon } from 'frappe-ui'
|
||||
import { Popover, FeatherIcon } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -174,6 +175,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-start',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
|
||||
@ -1,14 +1,320 @@
|
||||
<template>
|
||||
<LayoutHeader>
|
||||
<template #left-header>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<LayoutHeader>
|
||||
<template #left-header>
|
||||
<ViewBreadcrumbs routeName="Dashboard" />
|
||||
</template>
|
||||
<template #right-header>
|
||||
<Button
|
||||
v-if="!editing"
|
||||
:label="__('Refresh')"
|
||||
@click="dashboardItems.reload"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideRefreshCcw class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!editing && isAdmin()"
|
||||
:label="__('Edit')"
|
||||
@click="enableEditing"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePenLine class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="editing"
|
||||
:label="__('Chart')"
|
||||
icon-left="plus"
|
||||
@click="showAddChartModal = true"
|
||||
/>
|
||||
<Button
|
||||
v-if="editing && isAdmin()"
|
||||
:label="__('Reset to default')"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideUndo2 class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="editing" :label="__('Cancel')" @click="cancel" />
|
||||
<Button
|
||||
v-if="editing"
|
||||
variant="solid"
|
||||
:label="__('Save')"
|
||||
:disabled="!dirty"
|
||||
:loading="saveDashboard.loading"
|
||||
@click="save"
|
||||
/>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
|
||||
<div class="p-5 pb-2 flex items-center gap-4">
|
||||
<Dropdown
|
||||
v-if="!showDatePicker"
|
||||
:options="options"
|
||||
class="form-control"
|
||||
v-model="preset"
|
||||
:placeholder="__('Select Range')"
|
||||
:button="{
|
||||
label: __(preset),
|
||||
class:
|
||||
'!w-full justify-start [&>span]:mr-auto [&>svg]:text-ink-gray-5 ',
|
||||
variant: 'outline',
|
||||
iconRight: 'chevron-down',
|
||||
iconLeft: 'calendar',
|
||||
}"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
<DateRangePicker
|
||||
v-else
|
||||
class="!w-48"
|
||||
ref="datePickerRef"
|
||||
:value="filters.period"
|
||||
variant="outline"
|
||||
:placeholder="__('Period')"
|
||||
@change="
|
||||
(v) =>
|
||||
updateFilter('period', v, () => {
|
||||
showDatePicker = false
|
||||
if (!v) {
|
||||
filters.period = getLastXDays()
|
||||
preset = 'Last 30 Days'
|
||||
} else {
|
||||
preset = formatter(v)
|
||||
}
|
||||
})
|
||||
"
|
||||
:formatter="formatRange"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
|
||||
</template>
|
||||
</DateRangePicker>
|
||||
<Link
|
||||
v-if="isAdmin() || isManager()"
|
||||
class="form-control w-48"
|
||||
variant="outline"
|
||||
:value="filters.user && getUser(filters.user).full_name"
|
||||
doctype="User"
|
||||
:filters="{ name: ['in', users.data.crmUsers?.map((u) => u.name)] }"
|
||||
@change="(v) => updateFilter('user', v)"
|
||||
:placeholder="__('Sales user')"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar
|
||||
v-if="filters.user"
|
||||
class="mr-2"
|
||||
:user="filters.user"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-y-scroll">
|
||||
<DashboardGrid
|
||||
class="pt-1"
|
||||
v-if="!dashboardItems.loading && dashboardItems.data"
|
||||
v-model="dashboardItems.data"
|
||||
:editing="editing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AddChartModal
|
||||
v-if="showAddChartModal"
|
||||
v-model="showAddChartModal"
|
||||
v-model:items="dashboardItems.data"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import AddChartModal from '@/components/Dashboard/AddChartModal.vue'
|
||||
import LucideRefreshCcw from '~icons/lucide/refresh-ccw'
|
||||
import LucideUndo2 from '~icons/lucide/undo-2'
|
||||
import LucidePenLine from '~icons/lucide/pen-line'
|
||||
import DashboardGrid from '@/components/Dashboard/DashboardGrid.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import { Breadcrumbs } from 'frappe-ui'
|
||||
let title = 'Dashboard'
|
||||
const breadcrumbs = [{ label: title, route: { name: 'Dashboard' } }]
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { copy } from '@/utils'
|
||||
import { getLastXDays, formatter, formatRange } from '@/utils/dashboard'
|
||||
import {
|
||||
usePageMeta,
|
||||
createResource,
|
||||
DateRangePicker,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { ref, reactive, computed, provide } from 'vue'
|
||||
|
||||
const { users, getUser, isManager, isAdmin } = usersStore()
|
||||
|
||||
const editing = ref(false)
|
||||
|
||||
const showDatePicker = ref(false)
|
||||
const datePickerRef = ref(null)
|
||||
const preset = ref('Last 30 Days')
|
||||
const showAddChartModal = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
period: getLastXDays(),
|
||||
user: null,
|
||||
})
|
||||
|
||||
const fromDate = computed(() => {
|
||||
if (!filters.period) return null
|
||||
return filters.period.split(',')[0]
|
||||
})
|
||||
|
||||
const toDate = computed(() => {
|
||||
if (!filters.period) return null
|
||||
return filters.period.split(',')[1]
|
||||
})
|
||||
|
||||
function updateFilter(key: string, value: any, callback?: () => void) {
|
||||
filters[key] = value
|
||||
callback?.()
|
||||
dashboardItems.reload()
|
||||
}
|
||||
|
||||
const options = computed(() => [
|
||||
{
|
||||
group: 'Presets',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Last 7 Days',
|
||||
onClick: () => {
|
||||
preset.value = 'Last 7 Days'
|
||||
filters.period = getLastXDays(7)
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 30 Days',
|
||||
onClick: () => {
|
||||
preset.value = 'Last 30 Days'
|
||||
filters.period = getLastXDays(30)
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 60 Days',
|
||||
onClick: () => {
|
||||
preset.value = 'Last 60 Days'
|
||||
filters.period = getLastXDays(60)
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 90 Days',
|
||||
onClick: () => {
|
||||
preset.value = 'Last 90 Days'
|
||||
filters.period = getLastXDays(90)
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Custom Range',
|
||||
onClick: () => {
|
||||
showDatePicker.value = true
|
||||
setTimeout(() => datePickerRef.value?.open(), 0)
|
||||
preset.value = 'Custom Range'
|
||||
filters.period = null // Reset period to allow custom date selection
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const dashboardItems = createResource({
|
||||
url: 'crm.api.dashboard.get_dashboard',
|
||||
cache: ['Analytics', 'ManagerDashboard'],
|
||||
makeParams() {
|
||||
return {
|
||||
from_date: fromDate.value,
|
||||
to_date: toDate.value,
|
||||
user: filters.user,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const dirty = computed(() => {
|
||||
if (!editing.value) return false
|
||||
return JSON.stringify(dashboardItems.data) !== JSON.stringify(oldItems.value)
|
||||
})
|
||||
|
||||
const oldItems = ref([])
|
||||
|
||||
provide('fromDate', fromDate)
|
||||
provide('toDate', toDate)
|
||||
provide('filters', filters)
|
||||
|
||||
function enableEditing() {
|
||||
editing.value = true
|
||||
oldItems.value = copy(dashboardItems.data)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = false
|
||||
dashboardItems.data = copy(oldItems.value)
|
||||
}
|
||||
|
||||
const saveDashboard = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
method: 'POST',
|
||||
onSuccess: () => {
|
||||
dashboardItems.reload()
|
||||
editing.value = false
|
||||
},
|
||||
})
|
||||
|
||||
function save() {
|
||||
const dashboardItemsCopy = copy(dashboardItems.data)
|
||||
|
||||
dashboardItemsCopy.forEach((item: any) => {
|
||||
delete item.data
|
||||
})
|
||||
|
||||
saveDashboard.submit({
|
||||
doctype: 'CRM Dashboard',
|
||||
name: 'Manager Dashboard',
|
||||
fieldname: 'layout',
|
||||
value: JSON.stringify(dashboardItemsCopy),
|
||||
})
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
createResource({
|
||||
url: 'crm.api.dashboard.reset_to_default',
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
dashboardItems.reload()
|
||||
editing.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return { title: __('CRM Dashboard') }
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -773,7 +773,7 @@ const showLostReasonModal = ref(false)
|
||||
|
||||
function setLostReason() {
|
||||
if (
|
||||
document.doc.status !== 'Lost' ||
|
||||
getDealStatus(document.doc.status).type !== 'Lost' ||
|
||||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
|
||||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
|
||||
) {
|
||||
@ -785,7 +785,7 @@ function setLostReason() {
|
||||
}
|
||||
|
||||
function beforeStatusChange(data) {
|
||||
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
|
||||
if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
|
||||
setLostReason()
|
||||
} else {
|
||||
document.save.submit(null, {
|
||||
|
||||
@ -643,7 +643,7 @@ const showLostReasonModal = ref(false)
|
||||
|
||||
function setLostReason() {
|
||||
if (
|
||||
document.doc.status !== 'Lost' ||
|
||||
getDealStatus(document.doc.status).type !== 'Lost' ||
|
||||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
|
||||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
|
||||
) {
|
||||
@ -655,7 +655,7 @@ function setLostReason() {
|
||||
}
|
||||
|
||||
function beforeStatusChange(data) {
|
||||
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
|
||||
if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
|
||||
setLostReason()
|
||||
} else {
|
||||
document.save.submit(null, {
|
||||
|
||||
@ -13,6 +13,11 @@ const routes = [
|
||||
name: 'Notifications',
|
||||
component: () => import('@/pages/MobileNotification.vue'),
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/pages/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
alias: '/leads',
|
||||
path: '/leads/view/:viewType?',
|
||||
|
||||
@ -28,7 +28,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
||||
|
||||
const dealStatuses = createListResource({
|
||||
doctype: 'CRM Deal Status',
|
||||
fields: ['name', 'color', 'position'],
|
||||
fields: ['name', 'color', 'position', 'type'],
|
||||
orderBy: 'position asc',
|
||||
cache: 'deal-statuses',
|
||||
initialData: [],
|
||||
|
||||
@ -50,15 +50,19 @@ export const usersStore = defineStore('crm-users', () => {
|
||||
}
|
||||
|
||||
function isAdmin(email) {
|
||||
return getUser(email).role === 'System Manager' || getUser(email).is_admin
|
||||
return getUser(email).role === 'System Manager'
|
||||
}
|
||||
|
||||
function isManager(email) {
|
||||
return getUser(email).is_manager
|
||||
return getUser(email).role === 'Sales Manager' || isAdmin(email)
|
||||
}
|
||||
|
||||
function isAgent(email) {
|
||||
return getUser(email).is_agent
|
||||
function isSalesUser(email) {
|
||||
return getUser(email).role === 'Sales User'
|
||||
}
|
||||
|
||||
function isTelephonyAgent(email) {
|
||||
return getUser(email).is_telphony_agent
|
||||
}
|
||||
|
||||
function getUserRole(email) {
|
||||
@ -74,7 +78,8 @@ export const usersStore = defineStore('crm-users', () => {
|
||||
getUser,
|
||||
isAdmin,
|
||||
isManager,
|
||||
isAgent,
|
||||
isSalesUser,
|
||||
isTelephonyAgent,
|
||||
getUserRole,
|
||||
}
|
||||
})
|
||||
|
||||
28
frontend/src/utils/dashboard.ts
Normal file
28
frontend/src/utils/dashboard.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { dayjs } from "frappe-ui"
|
||||
|
||||
export function getLastXDays(range: number = 30): string | null {
|
||||
const today = new Date()
|
||||
const lastXDate = new Date(today)
|
||||
lastXDate.setDate(today.getDate() - range)
|
||||
|
||||
return `${dayjs(lastXDate).format('YYYY-MM-DD')},${dayjs(today).format(
|
||||
'YYYY-MM-DD',
|
||||
)}`
|
||||
}
|
||||
|
||||
export function formatter(range: string) {
|
||||
let [from, to] = range.split(',')
|
||||
return `${formatRange(from)} to ${formatRange(to)}`
|
||||
}
|
||||
|
||||
export function formatRange(date: string) {
|
||||
const dateObj = new Date(date)
|
||||
return dateObj.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year:
|
||||
dateObj.getFullYear() === new Date().getFullYear()
|
||||
? undefined
|
||||
: 'numeric',
|
||||
})
|
||||
}
|
||||
@ -531,3 +531,8 @@ export function TemplateOption({ active, option, theme, icon, onClick }) {
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function copy(obj) {
|
||||
if (!obj) return obj
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
69
yarn.lock
69
yarn.lock
@ -952,6 +952,13 @@
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/core@^1.7.2":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd"
|
||||
integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.10"
|
||||
|
||||
"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.7":
|
||||
version "1.6.12"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
|
||||
@ -968,6 +975,19 @@
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
"@floating-ui/utils" "^0.2.9"
|
||||
|
||||
"@floating-ui/dom@^1.7.0":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45"
|
||||
integrity sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.7.2"
|
||||
"@floating-ui/utils" "^0.2.10"
|
||||
|
||||
"@floating-ui/utils@^0.2.10":
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
|
||||
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
|
||||
|
||||
"@floating-ui/utils@^0.2.8":
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
|
||||
@ -1022,6 +1042,11 @@
|
||||
local-pkg "^1.0.0"
|
||||
mlly "^1.7.4"
|
||||
|
||||
"@interactjs/types@1.10.27":
|
||||
version "1.10.27"
|
||||
resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.27.tgz#10afd71cef2498e2b5192cf0d46f937d8ceb767f"
|
||||
integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==
|
||||
|
||||
"@internationalized/date@^3.5.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.7.0.tgz#23a4956308ee108e308517a7137c69ab8f5f2ad9"
|
||||
@ -1095,6 +1120,11 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@juggle/resize-observer@^3.4.0":
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
|
||||
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
|
||||
|
||||
"@nodelib/fs.scandir@2.1.5":
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
|
||||
@ -1582,6 +1612,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
|
||||
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
|
||||
|
||||
"@vexip-ui/hooks@^2.8.0":
|
||||
version "2.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@vexip-ui/hooks/-/hooks-2.9.2.tgz#3c6ba9670f1a4ac4211b05279e18657a3c1921ba"
|
||||
integrity sha512-zdwcTZUHYD/5aqndmUulyia4tPMI3FB09PUn674hZiQlkslO1KiH56WAI8R75wbvzPSmmhl5IA3VcbBZeaFEcw==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.7.0"
|
||||
"@juggle/resize-observer" "^3.4.0"
|
||||
"@vexip-ui/utils" "2.16.4"
|
||||
|
||||
"@vexip-ui/utils@2.16.4", "@vexip-ui/utils@^2.16.1":
|
||||
version "2.16.4"
|
||||
resolved "https://registry.yarnpkg.com/@vexip-ui/utils/-/utils-2.16.4.tgz#3429376a8f9e88040e969c21f14e70fe25d36127"
|
||||
integrity sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==
|
||||
|
||||
"@vitejs/plugin-vue-jsx@^3.0.1":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz#9953fd9456539e1f0f253bf0fcd1289e66c67cd1"
|
||||
@ -2572,10 +2616,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.166:
|
||||
version "0.1.166"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3"
|
||||
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw==
|
||||
frappe-ui@^0.1.171:
|
||||
version "0.1.171"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.171.tgz#10c582ea62292461ff37bb0b3ac2269409a373e9"
|
||||
integrity sha512-hIwban7j7qa+n/F6bZ+B78jYyGGj1gnibR/k0Kdx1SYPCfMdYr2TfZA8ySpbIvqWpeYxCus6nS4MD+wf0DpUOw==
|
||||
dependencies:
|
||||
"@floating-ui/vue" "^1.1.6"
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
@ -2608,6 +2652,7 @@ frappe-ui@^0.1.166:
|
||||
dompurify "^3.2.6"
|
||||
echarts "^5.6.0"
|
||||
feather-icons "^4.28.0"
|
||||
grid-layout-plus "^1.1.0"
|
||||
highlight.js "^11.11.1"
|
||||
idb-keyval "^6.2.0"
|
||||
lowlight "^3.3.0"
|
||||
@ -2773,6 +2818,15 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
|
||||
grid-layout-plus@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/grid-layout-plus/-/grid-layout-plus-1.1.0.tgz#4c6610ff3aa39ddea2953861c224d1914bf5a33d"
|
||||
integrity sha512-Q5uj0U5nx6xfHg8G1CDRJAEg+/40RVJl5jjRImcRwC78BxoJrEkTneT1pyxYMlbZ8fpGPT6QdHJQkD4+W6gt5A==
|
||||
dependencies:
|
||||
"@vexip-ui/hooks" "^2.8.0"
|
||||
"@vexip-ui/utils" "^2.16.1"
|
||||
interactjs "^1.10.27"
|
||||
|
||||
has-bigints@^1.0.2:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
|
||||
@ -2854,6 +2908,13 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
interactjs@^1.10.27:
|
||||
version "1.10.27"
|
||||
resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.27.tgz#16499aba4987a5ccfdaddca7d1ba7bb1118e14d0"
|
||||
integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==
|
||||
dependencies:
|
||||
"@interactjs/types" "1.10.27"
|
||||
|
||||
internal-slot@^1.0.7, internal-slot@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user