Merge pull request #1043 from frappe/main-hotfix

This commit is contained in:
Shariq Ansari 2025-07-15 15:22:40 +05:30 committed by GitHub
commit b0989566d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 3115 additions and 375 deletions

1142
crm/api/dashboard.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,9 +23,6 @@ def get_users():
if frappe.session.user == user.name: if frappe.session.user == user.name:
user.session_user = True 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.roles = frappe.get_roles(user.name)
user.role = "" user.role = ""
@ -42,7 +39,7 @@ def get_users():
if frappe.session.user == user.name: if frappe.session.user == user.name:
user.session_user = True 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 = [] crm_users = []

View 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) {
// },
// });

View 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"
}

View 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

View 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

View File

@ -18,9 +18,11 @@
"lost_notes", "lost_notes",
"section_break_jgpm", "section_break_jgpm",
"probability", "probability",
"expected_deal_value",
"deal_value", "deal_value",
"column_break_kpxa", "column_break_kpxa",
"close_date", "expected_closure_date",
"closed_date",
"contacts_tab", "contacts_tab",
"contacts", "contacts",
"contact", "contact",
@ -37,6 +39,7 @@
"column_break_xbyf", "column_break_xbyf",
"territory", "territory",
"currency", "currency",
"exchange_rate",
"annual_revenue", "annual_revenue",
"industry", "industry",
"person_section", "person_section",
@ -93,11 +96,6 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website" "label": "Website"
}, },
{
"fieldname": "close_date",
"fieldtype": "Date",
"label": "Close Date"
},
{ {
"fieldname": "next_step", "fieldname": "next_step",
"fieldtype": "Data", "fieldtype": "Data",
@ -409,12 +407,35 @@
"fieldtype": "Text", "fieldtype": "Text",
"label": "Lost Notes", "label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\"" "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, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-05 12:25:05.927806", "modified": "2025-07-13 11:54:20.608489",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal", "name": "CRM Deal",

View File

@ -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 ( from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
add_status_change_log, add_status_change_log,
) )
from crm.utils import get_exchange_rate
class CRMDeal(Document): class CRMDeal(Document):
@ -24,8 +25,11 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner) self.assign_agent(self.deal_owner)
if self.has_value_changed("status"): if self.has_value_changed("status"):
add_status_change_log(self) 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_forcasting_fields()
self.validate_lost_reason() self.validate_lost_reason()
self.update_exchange_rate()
def after_insert(self): def after_insert(self):
if self.deal_owner: if self.deal_owner:
@ -162,12 +166,21 @@ class CRMDeal(Document):
""" """
Validate the lost reason if the status is set to "Lost". 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: if not self.lost_reason:
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError) frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
elif self.lost_reason == "Other" and not self.lost_notes: elif self.lost_reason == "Other" and not self.lost_notes:
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError) 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 @staticmethod
def default_list_data(): def default_list_data():
columns = [ columns = [

View File

@ -7,9 +7,11 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"deal_status", "deal_status",
"color", "type",
"position", "position",
"probability" "column_break_ojiu",
"probability",
"color"
], ],
"fields": [ "fields": [
{ {
@ -39,12 +41,24 @@
"fieldtype": "Percent", "fieldtype": "Percent",
"in_list_view": 1, "in_list_view": 1,
"label": "Probability" "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, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-01 12:06:42.937440", "modified": "2025-07-11 16:03:28.077955",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal Status", "name": "CRM Deal Status",

View File

@ -160,7 +160,7 @@ def add_forecasting_section(layout, doctype):
"columns": [ "columns": [
{ {
"name": "column_" + str(random_string(4)), "name": "column_" + str(random_string(4)),
"fields": ["close_date", "probability", "deal_value"], "fields": ["expected_closure_date", "probability", "expected_deal_value"],
} }
], ],
}, },

View File

@ -10,6 +10,7 @@
"organization_name", "organization_name",
"no_of_employees", "no_of_employees",
"currency", "currency",
"exchange_rate",
"annual_revenue", "annual_revenue",
"organization_logo", "organization_logo",
"column_break_pnpp", "column_break_pnpp",
@ -74,12 +75,18 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Address", "label": "Address",
"options": "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", "image_field": "organization_logo",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-09-17 18:37:10.341062", "modified": "2025-07-15 11:40:12.175598",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Organization", "name": "CRM Organization",
@ -111,7 +118,8 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -4,51 +4,65 @@
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from crm.utils import get_exchange_rate
class CRMOrganization(Document): class CRMOrganization(Document):
@staticmethod def validate(self):
def default_list_data(): self.update_exchange_rate()
columns = [
{ def update_exchange_rate(self):
'label': 'Organization', if self.has_value_changed("currency") or not self.exchange_rate:
'type': 'Data', system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
'key': 'organization_name', exchange_rate = 1
'width': '16rem', if self.currency and self.currency != system_currency:
}, exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
{
'label': 'Website', self.db_set("exchange_rate", exchange_rate)
'type': 'Data',
'key': 'website', @staticmethod
'width': '14rem', def default_list_data():
}, columns = [
{ {
'label': 'Industry', "label": "Organization",
'type': 'Link', "type": "Data",
'key': 'industry', "key": "organization_name",
'options': 'CRM Industry', "width": "16rem",
'width': '14rem', },
}, {
{ "label": "Website",
'label': 'Annual Revenue', "type": "Data",
'type': 'Currency', "key": "website",
'key': 'annual_revenue', "width": "14rem",
'width': '14rem', },
}, {
{ "label": "Industry",
'label': 'Last Modified', "type": "Link",
'type': 'Datetime', "key": "industry",
'key': 'modified', "options": "CRM Industry",
'width': '8rem', "width": "14rem",
}, },
] {
rows = [ "label": "Annual Revenue",
"name", "type": "Currency",
"organization_name", "key": "annual_revenue",
"organization_logo", "width": "14rem",
"website", },
"industry", {
"currency", "label": "Last Modified",
"annual_revenue", "type": "Datetime",
"modified", "key": "modified",
] "width": "8rem",
return {'columns': columns, 'rows': rows} },
]
rows = [
"name",
"organization_name",
"organization_logo",
"website",
"industry",
"currency",
"annual_revenue",
"modified",
]
return {"columns": columns, "rows": rows}

View File

@ -13,6 +13,8 @@
"column_break_mwmz", "column_break_mwmz",
"duration", "duration",
"last_status_change_log", "last_status_change_log",
"from_type",
"to_type",
"log_owner" "log_owner"
], ],
"fields": [ "fields": [
@ -61,18 +63,31 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Owner", "label": "Owner",
"options": "User" "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-01-06 13:26:40.597277", "modified": "2025-07-13 12:37:41.278584",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Status Change Log", "name": "CRM Status Change Log",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -1,15 +1,17 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe
from datetime import datetime from datetime import datetime
from frappe.utils import add_to_date, get_datetime
import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import add_to_date, get_datetime
class CRMStatusChangeLog(Document): class CRMStatusChangeLog(Document):
pass pass
def get_duration(from_date, to_date): def get_duration(from_date, to_date):
if not isinstance(from_date, datetime): if not isinstance(from_date, datetime):
from_date = get_datetime(from_date) from_date = get_datetime(from_date)
@ -18,28 +20,45 @@ def get_duration(from_date, to_date):
duration = to_date - from_date duration = to_date - from_date
return duration.total_seconds() return duration.total_seconds()
def add_status_change_log(doc): 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(): if not doc.is_new():
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None 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: if not doc.status_change_log and previous_status:
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1) now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
doc.append("status_change_log", { doc.append(
"from": previous_status, "status_change_log",
"to": "", {
"from_date": now_minus_one_minute, "from": previous_status,
"to_date": "", "from_type": previous_status_type or "",
"log_owner": frappe.session.user, "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 = doc.status_change_log[-1]
last_status_change.to = doc.status 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.to_date = datetime.now()
last_status_change.log_owner = frappe.session.user last_status_change.log_owner = frappe.session.user
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date) last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
doc.append("status_change_log", { doc.append(
"from": doc.status, "status_change_log",
"to": "", {
"from_date": datetime.now(), "from": doc.status,
"to_date": "", "from_type": to_status_type or "",
"log_owner": frappe.session.user, "to": "",
}) "to_type": "",
"from_date": datetime.now(),
"to_date": "",
"log_owner": frappe.session.user,
},
)

View File

@ -8,6 +8,7 @@
"defaults_tab", "defaults_tab",
"restore_defaults", "restore_defaults",
"enable_forecasting", "enable_forecasting",
"currency",
"branding_tab", "branding_tab",
"brand_name", "brand_name",
"brand_logo", "brand_logo",
@ -60,16 +61,23 @@
}, },
{ {
"default": "0", "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", "fieldname": "enable_forecasting",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Forecasting" "label": "Enable Forecasting"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Currency",
"options": "Currency"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-07-01 13:20:48.757603", "modified": "2025-07-13 11:58:34.857638",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "FCRM Settings", "name": "FCRM Settings",

View File

@ -17,6 +17,7 @@ class FCRMSettings(Document):
def validate(self): def validate(self):
self.do_not_allow_to_delete_if_standard() self.do_not_allow_to_delete_if_standard()
self.setup_forecasting() self.setup_forecasting()
self.make_currency_read_only()
def do_not_allow_to_delete_if_standard(self): def do_not_allow_to_delete_if_standard(self):
if not self.has_value_changed("dropdown_items"): if not self.has_value_changed("dropdown_items"):
@ -37,29 +38,39 @@ class FCRMSettings(Document):
delete_property_setter( delete_property_setter(
"CRM Deal", "CRM Deal",
"reqd", "reqd",
"close_date", "expected_closure_date",
) )
delete_property_setter( delete_property_setter(
"CRM Deal", "CRM Deal",
"reqd", "reqd",
"deal_value", "expected_deal_value",
) )
else: else:
make_property_setter( make_property_setter(
"CRM Deal", "CRM Deal",
"close_date", "expected_closure_date",
"reqd", "reqd",
1 if self.enable_forecasting else 0, 1 if self.enable_forecasting else 0,
"Check", "Check",
) )
make_property_setter( make_property_setter(
"CRM Deal", "CRM Deal",
"deal_value", "expected_deal_value",
"reqd", "reqd",
1 if self.enable_forecasting else 0, 1 if self.enable_forecasting else 0,
"Check", "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(): def get_standard_dropdown_items():
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")] return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]

View File

@ -4,6 +4,7 @@ import click
import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 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 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_default_lost_reasons()
add_standard_dropdown_items() add_standard_dropdown_items()
add_default_scripts() add_default_scripts()
create_default_manager_dashboard(force)
frappe.db.commit() frappe.db.commit()
@ -69,36 +71,43 @@ def add_default_deal_statuses():
statuses = { statuses = {
"Qualification": { "Qualification": {
"color": "gray", "color": "gray",
"type": "Open",
"probability": 10, "probability": 10,
"position": 1, "position": 1,
}, },
"Demo/Making": { "Demo/Making": {
"color": "orange", "color": "orange",
"type": "Ongoing",
"probability": 25, "probability": 25,
"position": 2, "position": 2,
}, },
"Proposal/Quotation": { "Proposal/Quotation": {
"color": "blue", "color": "blue",
"type": "Ongoing",
"probability": 50, "probability": 50,
"position": 3, "position": 3,
}, },
"Negotiation": { "Negotiation": {
"color": "yellow", "color": "yellow",
"type": "Ongoing",
"probability": 70, "probability": 70,
"position": 4, "position": 4,
}, },
"Ready to Close": { "Ready to Close": {
"color": "purple", "color": "purple",
"type": "Ongoing",
"probability": 90, "probability": 90,
"position": 5, "position": 5,
}, },
"Won": { "Won": {
"color": "green", "color": "green",
"type": "Won",
"probability": 100, "probability": 100,
"position": 6, "position": 6,
}, },
"Lost": { "Lost": {
"color": "red", "color": "red",
"type": "Lost",
"probability": 0, "probability": 0,
"position": 7, "position": 7,
}, },
@ -111,6 +120,7 @@ def add_default_deal_statuses():
doc = frappe.new_doc("CRM Deal Status") doc = frappe.new_doc("CRM Deal Status")
doc.deal_status = status doc.deal_status = status
doc.color = statuses[status]["color"] doc.color = statuses[status]["color"]
doc.type = statuses[status]["type"]
doc.probability = statuses[status]["probability"] doc.probability = statuses[status]["probability"]
doc.position = statuses[status]["position"] doc.position = statuses[status]["position"]
doc.insert() doc.insert()

View File

@ -242,19 +242,18 @@ def get_call_log_status(call_payload, direction="inbound"):
elif status == "failed": elif status == "failed":
return "Failed" return "Failed"
status = call_payload.get("DialCallStatus")
call_type = call_payload.get("CallType") 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" status = "No Answer"
elif call_type == "client-hangup" and dial_call_status == "canceled": elif call_type == "client-hangup" and status == "canceled":
status = "Canceled" status = "Canceled"
elif call_type == "incomplete" and dial_call_status == "failed": elif call_type == "incomplete" and status == "failed":
status = "Failed" status = "Failed"
elif call_type == "completed": elif call_type == "completed":
status = "Completed" status = "Completed"
elif dial_call_status == "busy": elif status == "busy":
status = "Ringing" status = "Ringing"
return status return status

File diff suppressed because it is too large Load Diff

View File

@ -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.update_layouts_to_new_format
crm.patches.v1_0.move_twilio_agent_to_telephony_agent crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025 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

View 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)

View File

@ -1,10 +1,14 @@
from frappe import frappe import functools
import frappe
import phonenumbers 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 frappe.utils import floor
from phonenumbers import NumberParseException from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF 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"): def parse_phone_number(phone_number, default_country="IN"):
@ -97,6 +101,7 @@ def seconds_to_duration(seconds):
else: else:
return "0s" return "0s"
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked # Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
def get_linked_docs(doc, method="Delete"): def get_linked_docs(doc, method="Delete"):
from frappe.model.rename_doc import get_link_fields from frappe.model.rename_doc import get_link_fields
@ -161,6 +166,7 @@ def get_linked_docs(doc, method="Delete"):
) )
return docs return docs
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked # Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
def get_dynamic_linked_docs(doc, method="Delete"): def get_dynamic_linked_docs(doc, method="Delete"):
docs = [] docs = []
@ -222,3 +228,59 @@ def get_dynamic_linked_docs(doc, method="Delete"):
} }
) )
return docs 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

View File

@ -12,6 +12,7 @@ declare module 'vue' {
Activities: typeof import('./src/components/Activities/Activities.vue')['default'] Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default'] ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.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'] AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default'] AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.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'] Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default'] AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.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'] BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default'] CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.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'] CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default'] CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.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'] 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'] DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default'] DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
DealModal: typeof import('./src/components/Modals/DealModal.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'] EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default'] EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.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'] EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default'] EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.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'] ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default'] ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.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'] FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default'] FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GenderIcon: typeof import('./src/components/Icons/GenderIcon.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'] GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default'] GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
Grid: typeof import('./src/components/Controls/Grid.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'] GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default'] HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
HelpIcon: typeof import('./src/components/Icons/HelpIcon.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'] Icon: typeof import('./src/components/Icon.vue')['default']
IconPicker: typeof import('./src/components/IconPicker.vue')['default'] IconPicker: typeof import('./src/components/IconPicker.vue')['default']
ImageUploader: typeof import('./src/components/Controls/ImageUploader.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'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default'] LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default'] LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default'] LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default'] LucidePenLine: typeof import('~icons/lucide/pen-line')['default']
LucideSearch: typeof import('~icons/lucide/search')['default'] LucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['default']
LucideX: typeof import('~icons/lucide/x')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default'] MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default'] MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.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'] PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default'] PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
Popover: typeof import('./src/components/frappe-ui/Popover.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'] ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default'] QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default'] QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0", "@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2", "@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0", "@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.166", "frappe-ui": "^0.1.171",
"gemoji": "^8.1.0", "gemoji": "^8.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.1", "mime": "^4.0.1",

View File

@ -10,6 +10,8 @@
:size="attrs.size || 'sm'" :size="attrs.size || 'sm'"
:variant="attrs.variant" :variant="attrs.variant"
:placeholder="attrs.placeholder" :placeholder="attrs.placeholder"
:disabled="attrs.disabled"
:placement="attrs.placement"
:filterable="false" :filterable="false"
> >
<template #target="{ open, togglePopover }"> <template #target="{ open, togglePopover }">

View 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>

View 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>

View 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>

View File

@ -140,6 +140,7 @@
</template> </template>
<script setup> <script setup>
import LucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import CRMLogo from '@/components/Icons/CRMLogo.vue' import CRMLogo from '@/components/Icons/CRMLogo.vue'
import InviteIcon from '@/components/Icons/InviteIcon.vue' import InviteIcon from '@/components/Icons/InviteIcon.vue'
import ConvertIcon from '@/components/Icons/ConvertIcon.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 isFCSite = ref(window.is_fc_site)
const isDemoSite = ref(window.is_demo_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 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 = [ let _views = [
{ {
name: 'All Views', name: 'All Views',
hideLabel: true, hideLabel: true,
opened: true, opened: true,
views: links, views: links.filter((link) => {
if (link.condition) {
return link.condition()
}
return true
}),
}, },
] ]
if (getPublicViews().length) { if (getPublicViews().length) {

View File

@ -37,7 +37,13 @@
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { capture } from '@/telemetry' 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' import { ref, computed, onMounted, h } from 'vue'
const typeCheck = ['Check'] const typeCheck = ['Check']
@ -70,7 +76,7 @@ const fields = createResource({
}, },
transform: (data) => { transform: (data) => {
return data.filter((f) => f.hidden == 0 && f.read_only == 0) return data.filter((f) => f.hidden == 0 && f.read_only == 0)
} },
}) })
onMounted(() => { onMounted(() => {
@ -82,8 +88,8 @@ const recordCount = computed(() => props.selectedValues?.size || 0)
const field = ref({ const field = ref({
label: '', label: '',
type: '', fieldtype: '',
value: '', fieldname: '',
options: '', options: '',
}) })
@ -92,7 +98,7 @@ const loading = ref(false)
function updateValues() { function updateValues() {
let fieldVal = newValue.value let fieldVal = newValue.value
if (field.value.type == 'Check') { if (field.value.fieldtype == 'Check') {
fieldVal = fieldVal == 'Yes' ? 1 : 0 fieldVal = fieldVal == 'Yes' ? 1 : 0
} }
loading.value = true loading.value = true
@ -103,14 +109,14 @@ function updateValues() {
docnames: Array.from(props.selectedValues), docnames: Array.from(props.selectedValues),
action: 'update', action: 'update',
data: { data: {
[field.value.value]: fieldVal || null, [field.value.fieldname]: fieldVal || null,
}, },
} },
).then(() => { ).then(() => {
field.value = { field.value = {
label: '', label: '',
type: '', fieldtype: '',
value: '', fieldname: '',
options: '', options: '',
} }
newValue.value = '' newValue.value = ''
@ -137,9 +143,10 @@ function getSelectOptions(options) {
} }
function getValueComponent(f) { function getValueComponent(f) {
const { type, options } = f const { fieldtype, options } = f
if (typeSelect.includes(type) || typeCheck.includes(type)) { if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
const _options = type == 'Check' ? ['Yes', 'No'] : getSelectOptions(options) const _options =
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
return h(FormControl, { return h(FormControl, {
type: 'select', type: 'select',
options: _options.map((o) => ({ options: _options.map((o) => ({
@ -148,16 +155,16 @@ function getValueComponent(f) {
})), })),
modelValue: newValue.value, modelValue: newValue.value,
}) })
} else if (typeLink.includes(type)) { } else if (typeLink.includes(fieldtype)) {
if (type == 'Dynamic Link') { if (fieldtype == 'Dynamic Link') {
return h(FormControl, { type: 'text' }) return h(FormControl, { type: 'text' })
} }
return h(Link, { class: 'form-control', doctype: options }) return h(Link, { class: 'form-control', doctype: options })
} else if (typeNumber.includes(type)) { } else if (typeNumber.includes(fieldtype)) {
return h(FormControl, { type: 'number' }) return h(FormControl, { type: 'number' })
} else if (typeDate.includes(type)) { } else if (typeDate.includes(fieldtype)) {
return h(DatePicker) return h(DatePicker)
} else if (typeEditor.includes(type)) { } else if (typeEditor.includes(fieldtype)) {
return h(TextEditor, { return h(TextEditor, {
variant: 'outline', variant: 'outline',
editorClass: editorClass:

View File

@ -1,15 +1,15 @@
<template> <template>
<div <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 --> <!-- avatar and name -->
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" /> <EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div> <div>
<p class="text-sm font-semibold text-ink-gray-8"> <div class="text-p-base text-ink-gray-8">
{{ emailAccount.email_account_name }} {{ emailAccount.email_account_name }}
</p> </div>
<div class="text-sm text-ink-gray-4">{{ emailAccount.email_id }}</div> <div class="text-p-sm text-ink-gray-5">{{ emailAccount.email_id }}</div>
</div> </div>
</div> </div>
<div> <div>

View File

@ -30,11 +30,18 @@
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)" v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
class="mt-4" 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 <EmailAccountCard
:emailAccount="emailAccount" :emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', 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>
</div> </div>
<!-- fallback if no email accounts --> <!-- fallback if no email accounts -->

View File

@ -92,10 +92,10 @@
@click="() => emit('updateStep', 'edit-template', { ...template })" @click="() => emit('updateStep', 'edit-template', { ...template })"
> >
<div class="flex flex-col w-4/6 pr-5"> <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 }} {{ template.name }}
</div> </div>
<div class="text-p-base text-ink-gray-5 truncate"> <div class="text-p-sm text-ink-gray-5 truncate">
{{ template.subject }} {{ template.subject }}
</div> </div>
</div> </div>

View File

@ -1,31 +1,37 @@
<template> <template>
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8"> <div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
<div class="flex justify-between"> <!-- Header -->
<div class="flex flex-col gap-1 w-9/12"> <div class="flex px-2 justify-between">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5"> <div class="flex items-center gap-1 -ml-4 w-9/12">
{{ __('General') }} <Button
<Badge variant="ghost"
v-if="settings.isDirty" icon-left="chevron-left"
:label="__('Not Saved')" :label="__('Brand settings')"
variant="subtle" size="md"
theme="orange" @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"
</h2> />
<p class="text-p-base text-ink-gray-6"> <Badge
{{ __('Configure general settings for your CRM') }} v-if="settings.isDirty"
</p> :label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div> </div>
<div class="flex item-center space-x-2 w-3/12 justify-end"> <div class="flex item-center space-x-2 w-3/12 justify-end">
<Button <Button
variant="solid"
:label="__('Update')" :label="__('Update')"
icon-left="plus"
variant="solid"
:disabled="!settings.isDirty" :disabled="!settings.isDirty"
:loading="settings.loading"
@click="updateSettings" @click="updateSettings"
/> />
</div> </div>
</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"> <div class="flex w-full">
<FormControl <FormControl
type="text" type="text"
@ -36,7 +42,6 @@
</div> </div>
<!-- logo --> <!-- logo -->
<div class="flex flex-col justify-between gap-4"> <div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-8"> <span class="text-base font-semibold text-ink-gray-8">
{{ __('Logo') }} {{ __('Logo') }}
@ -71,7 +76,6 @@
</div> </div>
<!-- favicon --> <!-- favicon -->
<div class="flex flex-col justify-between gap-4"> <div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-8"> <span class="text-base font-semibold text-ink-gray-8">
{{ __('Favicon') }} {{ __('Favicon') }}
@ -104,33 +108,18 @@
</div> </div>
</div> </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> </div>
<div v-if="errorMessage">
<ErrorMessage :message="settings.save.error" /> <ErrorMessage :message="__(errorMessage)" />
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import ImageUploader from '@/components/Controls/ImageUploader.vue' import ImageUploader from '@/components/Controls/ImageUploader.vue'
import Grid from '@/components/Controls/Grid.vue' import { FormControl, ErrorMessage } from 'frappe-ui'
import { FormControl, Badge, ErrorMessage } from 'frappe-ui'
import { getSettings } from '@/stores/settings' import { getSettings } from '@/stores/settings'
import { showSettings } from '@/composables/settings' import { showSettings } from '@/composables/settings'
import { ref } from 'vue'
const { _settings: settings, setupBrand } = getSettings() const { _settings: settings, setupBrand } = getSettings()
@ -142,4 +131,7 @@ function updateSettings() {
}, },
}) })
} }
const emit = defineEmits(['updateStep'])
const errorMessage = ref('')
</script> </script>

View 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>

View File

@ -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>

View 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>

View File

@ -47,7 +47,7 @@ import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue' import Email2Icon from '@/components/Icons/Email2Icon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue' import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import Users from '@/components/Settings/Users.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 InviteUserPage from '@/components/Settings/InviteUserPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue' import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue' import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
@ -65,7 +65,7 @@ import {
import { Dialog, Avatar } from 'frappe-ui' import { Dialog, Avatar } from 'frappe-ui'
import { ref, markRaw, computed, watch, h } from 'vue' import { ref, markRaw, computed, watch, h } from 'vue'
const { isManager, isAgent, getUser } = usersStore() const { isManager, isTelephonyAgent, getUser } = usersStore()
const user = computed(() => getUser() || {}) const user = computed(() => getUser() || {})
@ -88,7 +88,7 @@ const tabs = computed(() => {
{ {
label: __('General'), label: __('General'),
icon: 'settings', icon: 'settings',
component: markRaw(GeneralSettings), component: markRaw(GeneralSettingsPage),
condition: () => isManager(), condition: () => isManager(),
}, },
{ {
@ -123,7 +123,7 @@ const tabs = computed(() => {
label: __('Telephony'), label: __('Telephony'),
icon: PhoneIcon, icon: PhoneIcon,
component: markRaw(TelephonySettings), component: markRaw(TelephonySettings),
condition: () => isManager() || isAgent(), condition: () => isManager() || isTelephonyAgent(),
}, },
{ {
label: __('WhatsApp'), label: __('WhatsApp'),
@ -138,7 +138,7 @@ const tabs = computed(() => {
condition: () => isManager(), condition: () => isManager(),
}, },
], ],
condition: () => isManager() || isAgent(), condition: () => isManager() || isTelephonyAgent(),
}, },
] ]

View File

@ -93,7 +93,7 @@ import { toast } from 'frappe-ui'
import { getRandom } from '@/utils' import { getRandom } from '@/utils'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
const { isManager, isAgent } = usersStore() const { isManager, isTelephonyAgent } = usersStore()
const twilioFields = createResource({ const twilioFields = createResource({
url: 'crm.api.doc.get_fields', url: 'crm.api.doc.get_fields',
@ -283,7 +283,7 @@ async function updateMedium() {
const error = ref('') const error = ref('')
function validateIfDefaultMediumIsEnabled() { function validateIfDefaultMediumIsEnabled() {
if (isAgent() && !isManager()) return true if (isTelephonyAgent() && !isManager()) return true
if (defaultCallingMedium.value === 'Twilio' && !twilio.doc.enabled) { if (defaultCallingMedium.value === 'Twilio' && !twilio.doc.enabled) {
error.value = __('Twilio is not enabled') error.value = __('Twilio is not enabled')

View File

@ -98,11 +98,11 @@
:label="user.full_name" :label="user.full_name"
size="xl" size="xl"
/> />
<div class="flex flex-col gap-1 ml-3"> <div class="flex flex-col ml-3">
<div class="flex items-center text-base text-ink-gray-8 h-4"> <div class="flex items-center text-p-base text-ink-gray-8">
{{ user.full_name }} {{ user.full_name }}
</div> </div>
<div class="text-base text-ink-gray-5"> <div class="text-p-sm text-ink-gray-5">
{{ user.name }} {{ user.name }}
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<template> <template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }"> <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 }"> <template #target="{ open: openPopover, togglePopover }">
<slot <slot
name="target" name="target"
@ -14,9 +14,9 @@
> >
<div class="w-full"> <div class="w-full">
<button <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" :class="inputClasses"
@click="() => togglePopover()" @click="() => !disabled && togglePopover()"
> >
<div <div
v-if="selectedValue" v-if="selectedValue"
@ -34,6 +34,7 @@
{{ placeholder || '' }} {{ placeholder || '' }}
</div> </div>
<FeatherIcon <FeatherIcon
v-if="!disabled"
name="chevron-down" name="chevron-down"
class="absolute h-4 w-4 text-ink-gray-5 right-2" class="absolute h-4 w-4 text-ink-gray-5 right-2"
aria-hidden="true" aria-hidden="true"
@ -142,7 +143,7 @@ import {
ComboboxOptions, ComboboxOptions,
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } 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' import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
const props = defineProps({ const props = defineProps({
@ -174,6 +175,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
placement: {
type: String,
default: 'bottom-start',
},
}) })
const emit = defineEmits(['update:modelValue', 'update:query', 'change']) const emit = defineEmits(['update:modelValue', 'update:query', 'change'])

View File

@ -1,14 +1,320 @@
<template> <template>
<LayoutHeader> <div class="flex flex-col h-full overflow-hidden">
<template #left-header> <LayoutHeader>
<Breadcrumbs :items="breadcrumbs" /> <template #left-header>
</template> <ViewBreadcrumbs routeName="Dashboard" />
</LayoutHeader> </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> </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 LayoutHeader from '@/components/LayoutHeader.vue'
import { Breadcrumbs } from 'frappe-ui' import Link from '@/components/Controls/Link.vue'
let title = 'Dashboard' import { usersStore } from '@/stores/users'
const breadcrumbs = [{ label: title, route: { name: 'Dashboard' } }] 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> </script>

View File

@ -773,7 +773,7 @@ const showLostReasonModal = ref(false)
function setLostReason() { function setLostReason() {
if ( if (
document.doc.status !== 'Lost' || getDealStatus(document.doc.status).type !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') || (document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes) (document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) { ) {
@ -785,7 +785,7 @@ function setLostReason() {
} }
function beforeStatusChange(data) { function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') { if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
setLostReason() setLostReason()
} else { } else {
document.save.submit(null, { document.save.submit(null, {

View File

@ -643,7 +643,7 @@ const showLostReasonModal = ref(false)
function setLostReason() { function setLostReason() {
if ( if (
document.doc.status !== 'Lost' || getDealStatus(document.doc.status).type !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') || (document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes) (document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) { ) {
@ -655,7 +655,7 @@ function setLostReason() {
} }
function beforeStatusChange(data) { function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') { if (data?.hasOwnProperty('status') && getDealStatus(data.status).type == 'Lost') {
setLostReason() setLostReason()
} else { } else {
document.save.submit(null, { document.save.submit(null, {

View File

@ -13,6 +13,11 @@ const routes = [
name: 'Notifications', name: 'Notifications',
component: () => import('@/pages/MobileNotification.vue'), component: () => import('@/pages/MobileNotification.vue'),
}, },
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/Dashboard.vue'),
},
{ {
alias: '/leads', alias: '/leads',
path: '/leads/view/:viewType?', path: '/leads/view/:viewType?',

View File

@ -28,7 +28,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
const dealStatuses = createListResource({ const dealStatuses = createListResource({
doctype: 'CRM Deal Status', doctype: 'CRM Deal Status',
fields: ['name', 'color', 'position'], fields: ['name', 'color', 'position', 'type'],
orderBy: 'position asc', orderBy: 'position asc',
cache: 'deal-statuses', cache: 'deal-statuses',
initialData: [], initialData: [],

View File

@ -50,15 +50,19 @@ export const usersStore = defineStore('crm-users', () => {
} }
function isAdmin(email) { function isAdmin(email) {
return getUser(email).role === 'System Manager' || getUser(email).is_admin return getUser(email).role === 'System Manager'
} }
function isManager(email) { function isManager(email) {
return getUser(email).is_manager return getUser(email).role === 'Sales Manager' || isAdmin(email)
} }
function isAgent(email) { function isSalesUser(email) {
return getUser(email).is_agent return getUser(email).role === 'Sales User'
}
function isTelephonyAgent(email) {
return getUser(email).is_telphony_agent
} }
function getUserRole(email) { function getUserRole(email) {
@ -74,7 +78,8 @@ export const usersStore = defineStore('crm-users', () => {
getUser, getUser,
isAdmin, isAdmin,
isManager, isManager,
isAgent, isSalesUser,
isTelephonyAgent,
getUserRole, getUserRole,
} }
}) })

View 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',
})
}

View File

@ -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))
}

View File

@ -952,6 +952,13 @@
dependencies: dependencies:
"@floating-ui/utils" "^0.2.8" "@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": "@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.7":
version "1.6.12" version "1.6.12"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556" 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/core" "^1.6.0"
"@floating-ui/utils" "^0.2.9" "@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": "@floating-ui/utils@^0.2.8":
version "0.2.8" version "0.2.8"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
@ -1022,6 +1042,11 @@
local-pkg "^1.0.0" local-pkg "^1.0.0"
mlly "^1.7.4" 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": "@internationalized/date@^3.5.0":
version "3.7.0" version "3.7.0"
resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.7.0.tgz#23a4956308ee108e308517a7137c69ab8f5f2ad9" resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.7.0.tgz#23a4956308ee108e308517a7137c69ab8f5f2ad9"
@ -1095,6 +1120,11 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@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": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" 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" resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== 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": "@vitejs/plugin-vue-jsx@^3.0.1":
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz#9953fd9456539e1f0f253bf0fcd1289e66c67cd1" 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" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.166: frappe-ui@^0.1.171:
version "0.1.166" version "0.1.171"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3" resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.171.tgz#10c582ea62292461ff37bb0b3ac2269409a373e9"
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw== integrity sha512-hIwban7j7qa+n/F6bZ+B78jYyGGj1gnibR/k0Kdx1SYPCfMdYr2TfZA8ySpbIvqWpeYxCus6nS4MD+wf0DpUOw==
dependencies: dependencies:
"@floating-ui/vue" "^1.1.6" "@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14" "@headlessui/vue" "^1.7.14"
@ -2608,6 +2652,7 @@ frappe-ui@^0.1.166:
dompurify "^3.2.6" dompurify "^3.2.6"
echarts "^5.6.0" echarts "^5.6.0"
feather-icons "^4.28.0" feather-icons "^4.28.0"
grid-layout-plus "^1.1.0"
highlight.js "^11.11.1" highlight.js "^11.11.1"
idb-keyval "^6.2.0" idb-keyval "^6.2.0"
lowlight "^3.3.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" 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== 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: has-bigints@^1.0.2:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" 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" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 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: internal-slot@^1.0.7, internal-slot@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"