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:
user.session_user = True
user.is_manager = "Sales Manager" in frappe.get_roles(user.name)
user.is_admin = user.name == "Administrator"
user.roles = frappe.get_roles(user.name)
user.role = ""
@ -42,7 +39,7 @@ def get_users():
if frappe.session.user == user.name:
user.session_user = True
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
crm_users = []

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",
"section_break_jgpm",
"probability",
"expected_deal_value",
"deal_value",
"column_break_kpxa",
"close_date",
"expected_closure_date",
"closed_date",
"contacts_tab",
"contacts",
"contact",
@ -37,6 +39,7 @@
"column_break_xbyf",
"territory",
"currency",
"exchange_rate",
"annual_revenue",
"industry",
"person_section",
@ -93,11 +96,6 @@
"fieldtype": "Data",
"label": "Website"
},
{
"fieldname": "close_date",
"fieldtype": "Date",
"label": "Close Date"
},
{
"fieldname": "next_step",
"fieldtype": "Data",
@ -409,12 +407,35 @@
"fieldtype": "Text",
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
},
{
"default": "1",
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate"
},
{
"fieldname": "expected_deal_value",
"fieldtype": "Currency",
"label": "Expected Deal Value",
"options": "currency"
},
{
"fieldname": "expected_closure_date",
"fieldtype": "Date",
"label": "Expected Closure Date"
},
{
"fieldname": "closed_date",
"fieldtype": "Date",
"label": "Closed Date"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-05 12:25:05.927806",
"modified": "2025-07-13 11:54:20.608489",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

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 (
add_status_change_log,
)
from crm.utils import get_exchange_rate
class CRMDeal(Document):
@ -24,8 +25,11 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner)
if self.has_value_changed("status"):
add_status_change_log(self)
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
self.closed_date = frappe.utils.nowdate()
self.validate_forcasting_fields()
self.validate_lost_reason()
self.update_exchange_rate()
def after_insert(self):
if self.deal_owner:
@ -162,12 +166,21 @@ class CRMDeal(Document):
"""
Validate the lost reason if the status is set to "Lost".
"""
if self.status == "Lost":
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
if not self.lost_reason:
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
elif self.lost_reason == "Other" and not self.lost_notes:
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
def update_exchange_rate(self):
if self.has_value_changed("currency") or not self.exchange_rate:
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
exchange_rate = 1
if self.currency and self.currency != system_currency:
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
self.db_set("exchange_rate", exchange_rate)
@staticmethod
def default_list_data():
columns = [

View File

@ -7,9 +7,11 @@
"engine": "InnoDB",
"field_order": [
"deal_status",
"color",
"type",
"position",
"probability"
"column_break_ojiu",
"probability",
"color"
],
"fields": [
{
@ -39,12 +41,24 @@
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Probability"
},
{
"default": "Open",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
},
{
"fieldname": "column_break_ojiu",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-01 12:06:42.937440",
"modified": "2025-07-11 16:03:28.077955",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal Status",

View File

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

View File

@ -10,6 +10,7 @@
"organization_name",
"no_of_employees",
"currency",
"exchange_rate",
"annual_revenue",
"organization_logo",
"column_break_pnpp",
@ -74,12 +75,18 @@
"fieldtype": "Link",
"label": "Address",
"options": "Address"
},
{
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate"
}
],
"image_field": "organization_logo",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-17 18:37:10.341062",
"modified": "2025-07-15 11:40:12.175598",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Organization",
@ -111,7 +118,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
"defaults_tab",
"restore_defaults",
"enable_forecasting",
"currency",
"branding_tab",
"brand_name",
"brand_logo",
@ -60,16 +61,23 @@
},
{
"default": "0",
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting",
"fieldtype": "Check",
"label": "Enable Forecasting"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Currency",
"options": "Currency"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-01 13:20:48.757603",
"modified": "2025-07-13 11:58:34.857638",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

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

View File

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

View File

@ -242,19 +242,18 @@ def get_call_log_status(call_payload, direction="inbound"):
elif status == "failed":
return "Failed"
status = call_payload.get("DialCallStatus")
call_type = call_payload.get("CallType")
dial_call_status = call_payload.get("DialCallStatus")
status = call_payload.get("DialCallStatus") or call_payload.get("Status")
if call_type == "incomplete" and dial_call_status == "no-answer":
if call_type == "incomplete" and status == "no-answer":
status = "No Answer"
elif call_type == "client-hangup" and dial_call_status == "canceled":
elif call_type == "client-hangup" and status == "canceled":
status = "Canceled"
elif call_type == "incomplete" and dial_call_status == "failed":
elif call_type == "incomplete" and status == "failed":
status = "Failed"
elif call_type == "completed":
status = "Completed"
elif dial_call_status == "busy":
elif status == "busy":
status = "Ringing"
return status

File diff suppressed because it is too large Load Diff

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

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 requests
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.utils import floor
from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF
from frappe.model.docstatus import DocStatus
from frappe.model.dynamic_links import get_dynamic_link_map
def parse_phone_number(phone_number, default_country="IN"):
@ -97,6 +101,7 @@ def seconds_to_duration(seconds):
else:
return "0s"
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
def get_linked_docs(doc, method="Delete"):
from frappe.model.rename_doc import get_link_fields
@ -161,6 +166,7 @@ def get_linked_docs(doc, method="Delete"):
)
return docs
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
def get_dynamic_linked_docs(doc, method="Delete"):
docs = []
@ -222,3 +228,59 @@ def get_dynamic_linked_docs(doc, method="Delete"):
}
)
return docs
def is_admin(user: str | None = None) -> bool:
"""
Check whether `user` is an admin
:param user: User to check against, defaults to current user
:return: Whether `user` is an admin
"""
user = user or frappe.session.user
return user == "Administrator"
def is_sales_user(user: str | None = None) -> bool:
"""
Check whether `user` is an agent
:param user: User to check against, defaults to current user
:return: Whether `user` is an agent
"""
user = user or frappe.session.user
return is_admin() or "Sales Manager" in frappe.get_roles(user) or "Sales User" in frappe.get_roles(user)
def sales_user_only(fn):
"""Decorator to validate if user is an agent."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not is_sales_user():
frappe.throw(
msg=_("You are not permitted to access this resource."),
title=_("Not Allowed"),
exc=frappe.PermissionError,
)
return fn(*args, **kwargs)
return wrapper
def get_exchange_rate(from_currency, to_currency, date=None):
if not date:
date = "latest"
url = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
response = requests.get(url)
if response.status_code == 200:
data = response.json()
rate = data["rates"].get(to_currency)
return rate
else:
frappe.throw(_("Failed to fetch historical exchange rate from external API. Please try again later."))
return None

@ -1 +1 @@
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
Subproject commit b295b54aaa3a2a22f455df819d87433dc6b9ff6a

View File

@ -12,6 +12,7 @@ declare module 'vue' {
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
@ -31,6 +32,7 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
@ -61,7 +63,9 @@ declare module 'vue' {
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
@ -98,11 +102,9 @@ declare module 'vue' {
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
@ -127,7 +129,8 @@ declare module 'vue' {
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
GeneralSettings: typeof import('./src/components/Settings/General/GeneralSettings.vue')['default']
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.vue')['default']
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
@ -138,6 +141,7 @@ declare module 'vue' {
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
HomeActions: typeof import('./src/components/Settings/General/HomeActions.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default']
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
@ -163,11 +167,10 @@ declare module 'vue' {
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default']
LucideX: typeof import('~icons/lucide/x')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
LucidePenLine: typeof import('~icons/lucide/pen-line')['default']
LucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -201,7 +204,6 @@ declare module 'vue' {
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']

View File

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

View File

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

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>
<script setup>
import LucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import CRMLogo from '@/components/Icons/CRMLogo.vue'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import ConvertIcon from '@/components/Icons/ConvertIcon.vue'
@ -196,51 +197,62 @@ const isSidebarCollapsed = useStorage('isSidebarCollapsed', false)
const isFCSite = ref(window.is_fc_site)
const isDemoSite = ref(window.is_demo_site)
const links = [
{
label: 'Leads',
icon: LeadsIcon,
to: 'Leads',
},
{
label: 'Deals',
icon: DealsIcon,
to: 'Deals',
},
{
label: 'Contacts',
icon: ContactsIcon,
to: 'Contacts',
},
{
label: 'Organizations',
icon: OrganizationsIcon,
to: 'Organizations',
},
{
label: 'Notes',
icon: NoteIcon,
to: 'Notes',
},
{
label: 'Tasks',
icon: TaskIcon,
to: 'Tasks',
},
{
label: 'Call Logs',
icon: PhoneIcon,
to: 'Call Logs',
},
]
const allViews = computed(() => {
const links = [
{
label: 'Dashboard',
icon: LucideLayoutDashboard,
to: 'Dashboard',
condition: () => isManager(),
},
{
label: 'Leads',
icon: LeadsIcon,
to: 'Leads',
},
{
label: 'Deals',
icon: DealsIcon,
to: 'Deals',
},
{
label: 'Contacts',
icon: ContactsIcon,
to: 'Contacts',
},
{
label: 'Organizations',
icon: OrganizationsIcon,
to: 'Organizations',
},
{
label: 'Notes',
icon: NoteIcon,
to: 'Notes',
},
{
label: 'Tasks',
icon: TaskIcon,
to: 'Tasks',
},
{
label: 'Call Logs',
icon: PhoneIcon,
to: 'Call Logs',
},
]
let _views = [
{
name: 'All Views',
hideLabel: true,
opened: true,
views: links,
views: links.filter((link) => {
if (link.condition) {
return link.condition()
}
return true
}),
},
]
if (getPublicViews().length) {

View File

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

View File

@ -1,15 +1,15 @@
<template>
<div
class="flex items-center justify-between p-1 py-3 border-b border-outline-gray-modals cursor-pointer"
class="flex items-center justify-between px-2 py-3 border-outline-gray-modals cursor-pointer hover:bg-surface-menu-bar rounded"
>
<!-- avatar and name -->
<div class="flex items-center justify-between gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div>
<p class="text-sm font-semibold text-ink-gray-8">
<div class="text-p-base text-ink-gray-8">
{{ emailAccount.email_account_name }}
</p>
<div class="text-sm text-ink-gray-4">{{ emailAccount.email_id }}</div>
</div>
<div class="text-p-sm text-ink-gray-5">{{ emailAccount.email_id }}</div>
</div>
</div>
<div>

View File

@ -30,11 +30,18 @@
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
class="mt-4"
>
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
<div
v-for="(emailAccount, i) in emailAccounts.data"
:key="emailAccount.name"
>
<EmailAccountCard
:emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', emailAccount)"
/>
<div
v-if="emailAccounts.data.length !== i + 1"
class="h-px border-t mx-2 border-outline-gray-modals"
/>
</div>
</div>
<!-- fallback if no email accounts -->

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
<Popover class="w-full" v-model:show="showOptions">
<Popover class="w-full" v-model:show="showOptions" :placement="placement">
<template #target="{ open: openPopover, togglePopover }">
<slot
name="target"
@ -14,9 +14,9 @@
>
<div class="w-full">
<button
class="relative flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
class="relative flex h-7 w-full items-center justify-between gap-2 rounded px-2 py-1 transition-colors"
:class="inputClasses"
@click="() => togglePopover()"
@click="() => !disabled && togglePopover()"
>
<div
v-if="selectedValue"
@ -34,6 +34,7 @@
{{ placeholder || '' }}
</div>
<FeatherIcon
v-if="!disabled"
name="chevron-down"
class="absolute h-4 w-4 text-ink-gray-5 right-2"
aria-hidden="true"
@ -142,7 +143,7 @@ import {
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { Popover, Button, FeatherIcon } from 'frappe-ui'
import { Popover, FeatherIcon } from 'frappe-ui'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
const props = defineProps({
@ -174,6 +175,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
placement: {
type: String,
default: 'bottom-start',
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])

View File

@ -1,14 +1,320 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
</template>
</LayoutHeader>
<div class="flex flex-col h-full overflow-hidden">
<LayoutHeader>
<template #left-header>
<ViewBreadcrumbs routeName="Dashboard" />
</template>
<template #right-header>
<Button
v-if="!editing"
:label="__('Refresh')"
@click="dashboardItems.reload"
>
<template #prefix>
<LucideRefreshCcw class="size-4" />
</template>
</Button>
<Button
v-if="!editing && isAdmin()"
:label="__('Edit')"
@click="enableEditing"
>
<template #prefix>
<LucidePenLine class="size-4" />
</template>
</Button>
<Button
v-if="editing"
:label="__('Chart')"
icon-left="plus"
@click="showAddChartModal = true"
/>
<Button
v-if="editing && isAdmin()"
:label="__('Reset to default')"
@click="resetToDefault"
>
<template #prefix>
<LucideUndo2 class="size-4" />
</template>
</Button>
<Button v-if="editing" :label="__('Cancel')" @click="cancel" />
<Button
v-if="editing"
variant="solid"
:label="__('Save')"
:disabled="!dirty"
:loading="saveDashboard.loading"
@click="save"
/>
</template>
</LayoutHeader>
<div class="p-5 pb-2 flex items-center gap-4">
<Dropdown
v-if="!showDatePicker"
:options="options"
class="form-control"
v-model="preset"
:placeholder="__('Select Range')"
:button="{
label: __(preset),
class:
'!w-full justify-start [&>span]:mr-auto [&>svg]:text-ink-gray-5 ',
variant: 'outline',
iconRight: 'chevron-down',
iconLeft: 'calendar',
}"
>
<template #prefix>
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
</template>
</Dropdown>
<DateRangePicker
v-else
class="!w-48"
ref="datePickerRef"
:value="filters.period"
variant="outline"
:placeholder="__('Period')"
@change="
(v) =>
updateFilter('period', v, () => {
showDatePicker = false
if (!v) {
filters.period = getLastXDays()
preset = 'Last 30 Days'
} else {
preset = formatter(v)
}
})
"
:formatter="formatRange"
>
<template #prefix>
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
</template>
</DateRangePicker>
<Link
v-if="isAdmin() || isManager()"
class="form-control w-48"
variant="outline"
:value="filters.user && getUser(filters.user).full_name"
doctype="User"
:filters="{ name: ['in', users.data.crmUsers?.map((u) => u.name)] }"
@change="(v) => updateFilter('user', v)"
:placeholder="__('Sales user')"
:hideMe="true"
>
<template #prefix>
<UserAvatar
v-if="filters.user"
class="mr-2"
:user="filters.user"
size="sm"
/>
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
</div>
<div class="w-full overflow-y-scroll">
<DashboardGrid
class="pt-1"
v-if="!dashboardItems.loading && dashboardItems.data"
v-model="dashboardItems.data"
:editing="editing"
/>
</div>
</div>
<AddChartModal
v-if="showAddChartModal"
v-model="showAddChartModal"
v-model:items="dashboardItems.data"
/>
</template>
<script setup>
<script setup lang="ts">
import AddChartModal from '@/components/Dashboard/AddChartModal.vue'
import LucideRefreshCcw from '~icons/lucide/refresh-ccw'
import LucideUndo2 from '~icons/lucide/undo-2'
import LucidePenLine from '~icons/lucide/pen-line'
import DashboardGrid from '@/components/Dashboard/DashboardGrid.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import { Breadcrumbs } from 'frappe-ui'
let title = 'Dashboard'
const breadcrumbs = [{ label: title, route: { name: 'Dashboard' } }]
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { copy } from '@/utils'
import { getLastXDays, formatter, formatRange } from '@/utils/dashboard'
import {
usePageMeta,
createResource,
DateRangePicker,
Dropdown,
Tooltip,
} from 'frappe-ui'
import { ref, reactive, computed, provide } from 'vue'
const { users, getUser, isManager, isAdmin } = usersStore()
const editing = ref(false)
const showDatePicker = ref(false)
const datePickerRef = ref(null)
const preset = ref('Last 30 Days')
const showAddChartModal = ref(false)
const filters = reactive({
period: getLastXDays(),
user: null,
})
const fromDate = computed(() => {
if (!filters.period) return null
return filters.period.split(',')[0]
})
const toDate = computed(() => {
if (!filters.period) return null
return filters.period.split(',')[1]
})
function updateFilter(key: string, value: any, callback?: () => void) {
filters[key] = value
callback?.()
dashboardItems.reload()
}
const options = computed(() => [
{
group: 'Presets',
hideLabel: true,
items: [
{
label: 'Last 7 Days',
onClick: () => {
preset.value = 'Last 7 Days'
filters.period = getLastXDays(7)
dashboardItems.reload()
},
},
{
label: 'Last 30 Days',
onClick: () => {
preset.value = 'Last 30 Days'
filters.period = getLastXDays(30)
dashboardItems.reload()
},
},
{
label: 'Last 60 Days',
onClick: () => {
preset.value = 'Last 60 Days'
filters.period = getLastXDays(60)
dashboardItems.reload()
},
},
{
label: 'Last 90 Days',
onClick: () => {
preset.value = 'Last 90 Days'
filters.period = getLastXDays(90)
dashboardItems.reload()
},
},
],
},
{
label: 'Custom Range',
onClick: () => {
showDatePicker.value = true
setTimeout(() => datePickerRef.value?.open(), 0)
preset.value = 'Custom Range'
filters.period = null // Reset period to allow custom date selection
},
},
])
const dashboardItems = createResource({
url: 'crm.api.dashboard.get_dashboard',
cache: ['Analytics', 'ManagerDashboard'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
})
const dirty = computed(() => {
if (!editing.value) return false
return JSON.stringify(dashboardItems.data) !== JSON.stringify(oldItems.value)
})
const oldItems = ref([])
provide('fromDate', fromDate)
provide('toDate', toDate)
provide('filters', filters)
function enableEditing() {
editing.value = true
oldItems.value = copy(dashboardItems.data)
}
function cancel() {
editing.value = false
dashboardItems.data = copy(oldItems.value)
}
const saveDashboard = createResource({
url: 'frappe.client.set_value',
method: 'POST',
onSuccess: () => {
dashboardItems.reload()
editing.value = false
},
})
function save() {
const dashboardItemsCopy = copy(dashboardItems.data)
dashboardItemsCopy.forEach((item: any) => {
delete item.data
})
saveDashboard.submit({
doctype: 'CRM Dashboard',
name: 'Manager Dashboard',
fieldname: 'layout',
value: JSON.stringify(dashboardItemsCopy),
})
}
function resetToDefault() {
createResource({
url: 'crm.api.dashboard.reset_to_default',
auto: true,
onSuccess: () => {
dashboardItems.reload()
editing.value = false
},
})
}
usePageMeta(() => {
return { title: __('CRM Dashboard') }
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

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:
"@floating-ui/utils" "^0.2.8"
"@floating-ui/core@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd"
integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.7":
version "1.6.12"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
@ -968,6 +975,19 @@
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.9"
"@floating-ui/dom@^1.7.0":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45"
integrity sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==
dependencies:
"@floating-ui/core" "^1.7.2"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@floating-ui/utils@^0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
@ -1022,6 +1042,11 @@
local-pkg "^1.0.0"
mlly "^1.7.4"
"@interactjs/types@1.10.27":
version "1.10.27"
resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.27.tgz#10afd71cef2498e2b5192cf0d46f937d8ceb767f"
integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==
"@internationalized/date@^3.5.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.7.0.tgz#23a4956308ee108e308517a7137c69ab8f5f2ad9"
@ -1095,6 +1120,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@juggle/resize-observer@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -1582,6 +1612,20 @@
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@vexip-ui/hooks@^2.8.0":
version "2.9.2"
resolved "https://registry.yarnpkg.com/@vexip-ui/hooks/-/hooks-2.9.2.tgz#3c6ba9670f1a4ac4211b05279e18657a3c1921ba"
integrity sha512-zdwcTZUHYD/5aqndmUulyia4tPMI3FB09PUn674hZiQlkslO1KiH56WAI8R75wbvzPSmmhl5IA3VcbBZeaFEcw==
dependencies:
"@floating-ui/dom" "^1.7.0"
"@juggle/resize-observer" "^3.4.0"
"@vexip-ui/utils" "2.16.4"
"@vexip-ui/utils@2.16.4", "@vexip-ui/utils@^2.16.1":
version "2.16.4"
resolved "https://registry.yarnpkg.com/@vexip-ui/utils/-/utils-2.16.4.tgz#3429376a8f9e88040e969c21f14e70fe25d36127"
integrity sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==
"@vitejs/plugin-vue-jsx@^3.0.1":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz#9953fd9456539e1f0f253bf0fcd1289e66c67cd1"
@ -2572,10 +2616,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.166:
version "0.1.166"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3"
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw==
frappe-ui@^0.1.171:
version "0.1.171"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.171.tgz#10c582ea62292461ff37bb0b3ac2269409a373e9"
integrity sha512-hIwban7j7qa+n/F6bZ+B78jYyGGj1gnibR/k0Kdx1SYPCfMdYr2TfZA8ySpbIvqWpeYxCus6nS4MD+wf0DpUOw==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
@ -2608,6 +2652,7 @@ frappe-ui@^0.1.166:
dompurify "^3.2.6"
echarts "^5.6.0"
feather-icons "^4.28.0"
grid-layout-plus "^1.1.0"
highlight.js "^11.11.1"
idb-keyval "^6.2.0"
lowlight "^3.3.0"
@ -2773,6 +2818,15 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
grid-layout-plus@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/grid-layout-plus/-/grid-layout-plus-1.1.0.tgz#4c6610ff3aa39ddea2953861c224d1914bf5a33d"
integrity sha512-Q5uj0U5nx6xfHg8G1CDRJAEg+/40RVJl5jjRImcRwC78BxoJrEkTneT1pyxYMlbZ8fpGPT6QdHJQkD4+W6gt5A==
dependencies:
"@vexip-ui/hooks" "^2.8.0"
"@vexip-ui/utils" "^2.16.1"
interactjs "^1.10.27"
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
@ -2854,6 +2908,13 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
interactjs@^1.10.27:
version "1.10.27"
resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.27.tgz#16499aba4987a5ccfdaddca7d1ba7bb1118e14d0"
integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==
dependencies:
"@interactjs/types" "1.10.27"
internal-slot@^1.0.7, internal-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"