1
0
forked from test/crm

Merge pull request #1021 from frappe/mergify/bp/main-hotfix/pr-979

This commit is contained in:
Shariq Ansari 2025-07-10 17:33:15 +05:30 committed by GitHub
commit 3a27fb91c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1803 additions and 171 deletions

695
crm/api/dashboard.py Normal file
View File

@ -0,0 +1,695 @@
import frappe
from frappe import _
from crm.utils import sales_user_only
@frappe.whitelist()
@sales_user_only
def get_number_card_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
"""
Get number card data for the dashboard.
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
is_sales_user = "Sales User" in frappe.get_roles(frappe.session.user)
if is_sales_user and not user:
user = frappe.session.user
lead_chart_data = get_lead_count(from_date, to_date, user, lead_conds)
deal_chart_data = get_deal_count(from_date, to_date, user, deal_conds)
get_won_deal_count_data = get_won_deal_count(from_date, to_date, user, deal_conds)
get_average_deal_value_data = get_average_deal_value(from_date, to_date, user, deal_conds)
get_average_time_to_close_data = get_average_time_to_close(from_date, to_date, user, deal_conds)
return [
lead_chart_data,
deal_chart_data,
get_won_deal_count_data,
get_average_deal_value_data,
get_average_time_to_close_data,
]
def get_lead_count(from_date, to_date, user="", conds="", return_result=False):
"""
Get lead count for the dashboard.
"""
diff = frappe.utils.date_diff(to_date, from_date)
if diff == 0:
diff = 1
if user:
conds += f" AND lead_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds}
THEN name
ELSE NULL
END) as current_month_leads,
COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s
{conds}
THEN name
ELSE NULL
END) as prev_month_leads
FROM `tabCRM Lead`
""",
{
"from_date": from_date,
"to_date": to_date,
"prev_from_date": frappe.utils.add_days(from_date, -diff),
},
as_dict=1,
)
if return_result:
return result
current_month_leads = result[0].current_month_leads or 0
prev_month_leads = result[0].prev_month_leads or 0
delta_in_percentage = (
(current_month_leads - prev_month_leads) / prev_month_leads * 100 if prev_month_leads else 0
)
return {
"title": _("Total Leads"),
"value": current_month_leads,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of leads"),
}
def get_deal_count(from_date, to_date, user="", conds="", return_result=False):
"""
Get deal count for the dashboard.
"""
diff = frappe.utils.date_diff(to_date, from_date)
if diff == 0:
diff = 1
if user:
conds += f" AND deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
{conds}
THEN name
ELSE NULL
END) as current_month_deals,
COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s
{conds}
THEN name
ELSE NULL
END) as prev_month_deals
FROM `tabCRM Deal`
""",
{
"from_date": from_date,
"to_date": to_date,
"prev_from_date": frappe.utils.add_days(from_date, -diff),
},
as_dict=1,
)
if return_result:
return result
current_month_deals = result[0].current_month_deals or 0
prev_month_deals = result[0].prev_month_deals or 0
delta_in_percentage = (
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
)
return {
"title": _("Total Deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of deals"),
}
def get_won_deal_count(from_date, to_date, user="", conds="", return_result=False):
"""
Get won deal count for the dashboard.
"""
diff = frappe.utils.date_diff(to_date, from_date)
if diff == 0:
diff = 1
if user:
conds += f" AND deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
COUNT(CASE
WHEN creation >= %(from_date)s AND creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND status = 'Won'
{conds}
THEN name
ELSE NULL
END) as current_month_deals,
COUNT(CASE
WHEN creation >= %(prev_from_date)s AND creation < %(from_date)s AND status = 'Won'
{conds}
THEN name
ELSE NULL
END) as prev_month_deals
FROM `tabCRM Deal`
""",
{
"from_date": from_date,
"to_date": to_date,
"prev_from_date": frappe.utils.add_days(from_date, -diff),
},
as_dict=1,
)
if return_result:
return result
current_month_deals = result[0].current_month_deals or 0
prev_month_deals = result[0].prev_month_deals or 0
delta_in_percentage = (
(current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0
)
return {
"title": _("Won Deals"),
"value": current_month_deals,
"delta": delta_in_percentage,
"deltaSuffix": "%",
"negativeIsBetter": False,
"tooltip": _("Total number of won deals"),
}
def get_average_deal_value(from_date, to_date, user="", conds="", return_result=False):
"""
Get average deal value for the dashboard.
"""
diff = frappe.utils.date_diff(to_date, from_date)
if diff == 0:
diff = 1
if user:
conds += f" AND deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
AVG(CASE
WHEN d.creation >= %(from_date)s AND d.creation < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND d.status != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as current_month_avg,
AVG(CASE
WHEN d.creation >= %(prev_from_date)s AND d.creation < %(from_date)s AND d.status != 'Lost'
{conds}
THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE NULL
END) as prev_month_avg
FROM `tabCRM Deal` AS d
""",
{
"from_date": from_date,
"to_date": to_date,
"prev_from_date": frappe.utils.add_days(from_date, -diff),
},
as_dict=1,
)
current_month_avg = result[0].current_month_avg or 0
prev_month_avg = result[0].prev_month_avg or 0
delta = current_month_avg - prev_month_avg if prev_month_avg else 0
return {
"title": _("Avg Deal Value"),
"value": current_month_avg,
"tooltip": _("Average deal value of ongoing & won deals"),
"prefix": get_base_currency_symbol(),
# "suffix": "K",
"delta": delta,
"deltaSuffix": "%",
}
def get_average_time_to_close(from_date, to_date, user="", conds="", return_result=False):
"""
Get average time to close deals for the dashboard.
"""
diff = frappe.utils.date_diff(to_date, from_date)
if diff == 0:
diff = 1
if user:
conds += f" AND d.deal_owner = '{user}'"
prev_from_date = frappe.utils.add_days(from_date, -diff)
prev_to_date = from_date
result = frappe.db.sql(
f"""
SELECT
AVG(CASE WHEN d.closed_on >= %(from_date)s AND d.closed_on < DATE_ADD(%(to_date)s, INTERVAL 1 DAY)
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as current_avg,
AVG(CASE WHEN d.closed_on >= %(prev_from_date)s AND d.closed_on < %(prev_to_date)s
THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_on) END) as prev_avg
FROM `tabCRM Deal` AS d
LEFT JOIN `tabCRM Lead` l ON d.lead = l.name
WHERE d.status = 'Won' AND d.closed_on IS NOT NULL
{conds}
""",
{
"from_date": from_date,
"to_date": to_date,
"prev_from_date": prev_from_date,
"prev_to_date": prev_to_date,
},
as_dict=1,
)
if return_result:
return result
current_avg = result[0].current_avg or 0
prev_avg = result[0].prev_avg or 0
delta = current_avg - prev_avg if prev_avg else 0
return {
"title": _("Avg Time to Close"),
"value": current_avg,
"tooltip": _("Average time taken from lead creation to deal closure"),
"suffix": " days",
"delta": delta,
"deltaSuffix": " days",
"negativeIsBetter": True,
}
@frappe.whitelist()
def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
"""
Get sales trend data for the dashboard.
[
{ date: new Date('2024-05-01'), leads: 45, deals: 23, won_deals: 12 },
{ date: new Date('2024-05-02'), leads: 50, deals: 30, won_deals: 15 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
lead_conds += f" AND lead_owner = '{user}'"
deal_conds += f" AND deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
DATE_FORMAT(date, '%%Y-%%m-%%d') AS date,
SUM(leads) AS leads,
SUM(deals) AS deals,
SUM(won_deals) AS won_deals
FROM (
SELECT
DATE(creation) AS date,
COUNT(*) AS leads,
0 AS deals,
0 AS won_deals
FROM `tabCRM Lead`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{lead_conds}
GROUP BY DATE(creation)
UNION ALL
SELECT
DATE(creation) AS date,
0 AS leads,
COUNT(*) AS deals,
SUM(CASE WHEN status = 'Won' THEN 1 ELSE 0 END) AS won_deals
FROM `tabCRM Deal`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY DATE(creation)
) AS daily
GROUP BY date
ORDER BY date
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return [
{
"date": frappe.utils.get_datetime(row.date).strftime("%Y-%m-%d"),
"leads": row.leads or 0,
"deals": row.deals or 0,
"won_deals": row.won_deals or 0,
}
for row in result
]
@frappe.whitelist()
def get_deals_by_salesperson(from_date="", to_date="", user="", deal_conds=""):
"""
Get deal data by salesperson for the dashboard.
[
{ salesperson: 'John Smith', deals: 45, value: 2300000 },
{ salesperson: 'Jane Doe', deals: 30, value: 1500000 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
deal_conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
IFNULL(u.full_name, d.deal_owner) AS salesperson,
COUNT(*) AS deals,
SUM(COALESCE(d.deal_value, 0) * IFNULL(d.exchange_rate, 1)) AS value
FROM `tabCRM Deal` AS d
LEFT JOIN `tabUser` AS u ON u.name = d.deal_owner
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY d.deal_owner
ORDER BY value DESC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return {
"data": result or [],
"currency_symbol": get_base_currency_symbol(),
}
@frappe.whitelist()
def get_deals_by_territory(from_date="", to_date="", user="", deal_conds=""):
"""
Get deal data by territory for the dashboard.
[
{ territory: 'North America', deals: 45, value: 2300000 },
{ territory: 'Europe', deals: 30, value: 1500000 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
deal_conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
IFNULL(d.territory, 'Empty') AS territory,
COUNT(*) AS deals,
SUM(COALESCE(d.deal_value, 0) * IFNULL(d.exchange_rate, 1)) AS value
FROM `tabCRM Deal` AS d
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY d.territory
ORDER BY value DESC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return {
"data": result or [],
"currency_symbol": get_base_currency_symbol(),
}
@frappe.whitelist()
def get_lost_deal_reasons(from_date="", to_date="", user="", deal_conds=""):
"""
Get lost deal reasons for the dashboard.
[
{ reason: 'Price too high', count: 20 },
{ reason: 'Competitor won', count: 15 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
deal_conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
d.lost_reason AS reason,
COUNT(*) AS count
FROM `tabCRM Deal` AS d
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND d.status = 'Lost'
{deal_conds}
GROUP BY d.lost_reason
HAVING reason IS NOT NULL AND reason != ''
ORDER BY count DESC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return result or []
@frappe.whitelist()
def get_forecasted_revenue(user="", deal_conds=""):
"""
Get forecasted revenue for the dashboard.
[
{ date: new Date('2024-05-01'), forecasted: 1200000, actual: 980000 },
{ date: new Date('2024-06-01'), forecasted: 1350000, actual: 1120000 },
{ date: new Date('2024-07-01'), forecasted: 1600000, actual: "" },
{ date: new Date('2024-08-01'), forecasted: 1500000, actual: "" },
...
]
"""
if user:
deal_conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
DATE_FORMAT(d.close_date, '%Y-%m') AS month,
SUM(
CASE
WHEN d.status = 'Lost' THEN d.deal_value * IFNULL(d.exchange_rate, 1)
ELSE d.deal_value * IFNULL(d.probability, 0) / 100 * IFNULL(d.exchange_rate, 1) -- forecasted
END
) AS forecasted,
SUM(
CASE
WHEN d.status = 'Won' THEN d.deal_value * IFNULL(d.exchange_rate, 1) -- actual
ELSE 0
END
) AS actual
FROM `tabCRM Deal` AS d
WHERE d.close_date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
{deal_conds}
GROUP BY DATE_FORMAT(d.close_date, '%Y-%m')
ORDER BY month
""",
as_dict=True,
)
if not result:
return []
for row in result:
row["month"] = frappe.utils.get_datetime(row["month"]).strftime("%Y-%m-01")
row["forecasted"] = row["forecasted"] or ""
row["actual"] = row["actual"] or ""
return {
"data": result or [],
"currency_symbol": get_base_currency_symbol(),
}
@frappe.whitelist()
def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
"""
Get funnel conversion data for the dashboard.
[
{ stage: 'Leads', count: 120 },
{ stage: 'Qualification', count: 100 },
{ stage: 'Negotiation', count: 80 },
{ stage: 'Ready to Close', count: 60 },
{ stage: 'Won', count: 30 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
lead_conds += f" AND lead_owner = '{user}'"
deal_conds += f" AND deal_owner = '{user}'"
result = []
# Get total leads
total_leads = frappe.db.sql(
f""" SELECT COUNT(*) AS count
FROM `tabCRM Lead`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{lead_conds}
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
total_leads_count = total_leads[0].count if total_leads else 0
result.append({"stage": "Leads", "count": total_leads_count})
# Get deal stages
all_deal_stages = frappe.get_all(
"CRM Deal Status", filters={"name": ["!=", "Lost"]}, order_by="position", pluck="name"
)
# Get deal counts for each stage
for i, stage in enumerate(all_deal_stages):
stages_to_count = all_deal_stages[i:]
placeholders = ", ".join(["%s"] * len(stages_to_count))
query = f"""
SELECT COUNT(*) as count
FROM `tabCRM Deal`
WHERE DATE(creation) BETWEEN %s AND %s
AND status IN ({placeholders})
{deal_conds}
"""
params = [from_date, to_date, *stages_to_count]
row = frappe.db.sql(query, params, as_dict=True)
result.append({"stage": stage, "count": row[0]["count"] if row else 0})
return result or []
@frappe.whitelist()
def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""):
"""
Get deal data by stage for the dashboard.
[
{ stage: 'Prospecting', count: 120 },
{ stage: 'Negotiation', count: 45 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
deal_conds += f" AND d.deal_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
d.status AS stage,
COUNT(*) AS count
FROM `tabCRM Deal` AS d
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
{deal_conds}
GROUP BY d.status
ORDER BY count DESC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return result or []
@frappe.whitelist()
def get_leads_by_source(from_date="", to_date="", user="", lead_conds=""):
"""
Get lead data by source for the dashboard.
[
{ source: 'Website', count: 120 },
{ source: 'Referral', count: 45 },
...
]
"""
if not from_date or not to_date:
from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate())
to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate())
if user:
lead_conds += f" AND lead_owner = '{user}'"
result = frappe.db.sql(
f"""
SELECT
IFNULL(source, 'Empty') AS source,
COUNT(*) AS count
FROM `tabCRM Lead`
WHERE DATE(creation) BETWEEN %(from)s AND %(to)s
{lead_conds}
GROUP BY source
ORDER BY count DESC
""",
{"from": from_date, "to": to_date},
as_dict=True,
)
return result or []
def get_base_currency_symbol():
"""
Get the base currency symbol from the system settings.
"""
base_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
return frappe.db.get_value("Currency", base_currency, "symbol") or ""

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

@ -21,6 +21,7 @@
"deal_value",
"column_break_kpxa",
"close_date",
"closed_on",
"contacts_tab",
"contacts",
"contact",
@ -37,6 +38,7 @@
"column_break_xbyf",
"territory",
"currency",
"exchange_rate",
"annual_revenue",
"industry",
"person_section",
@ -409,12 +411,24 @@
"fieldtype": "Text",
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
},
{
"fieldname": "closed_on",
"fieldtype": "Datetime",
"label": "Closed On"
},
{
"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"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-05 12:25:05.927806",
"modified": "2025-07-09 17:58:55.956639",
"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 self.status == "Won":
self.closed_on = frappe.utils.now_datetime()
self.validate_forcasting_fields()
self.validate_lost_reason()
self.update_exchange_rate()
def after_insert(self):
if self.deal_owner:
@ -168,6 +172,15 @@ class CRMDeal(Document):
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

@ -10,6 +10,7 @@
"organization_name",
"no_of_employees",
"currency",
"currency_exchange",
"annual_revenue",
"organization_logo",
"column_break_pnpp",
@ -74,12 +75,18 @@
"fieldtype": "Link",
"label": "Address",
"options": "Address"
},
{
"fieldname": "currency_exchange",
"fieldtype": "Link",
"label": "Currency Exchange",
"options": "CRM Currency Exchange"
}
],
"image_field": "organization_logo",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-17 18:37:10.341062",
"modified": "2025-07-09 14:32:23.893267",
"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

@ -8,6 +8,7 @@
"defaults_tab",
"restore_defaults",
"enable_forecasting",
"currency",
"branding_tab",
"brand_name",
"brand_logo",
@ -64,12 +65,19 @@
"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-10 16:35:25.030011",
"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"):
@ -60,6 +61,16 @@ class FCRMSettings(Document):
"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

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

View File

@ -31,6 +31,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']
@ -127,7 +128,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 +140,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,6 +166,7 @@ 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']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']

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

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

@ -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,513 @@
<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>
</LayoutHeader>
<div class="p-5 pb-3 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="p-5 pt-2 w-full overflow-y-scroll">
<div class="transition-all animate-fade-in duration-300">
<div
v-if="!numberCards.loading"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
>
<Tooltip
v-for="(config, index) in numberCards.data"
:text="config.tooltip"
>
<NumberChart
:key="index"
class="border rounded-md"
:config="config"
/>
</Tooltip>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div v-if="salesTrend.data" class="border rounded-md min-h-80">
<AxisChart :config="salesTrend.data" />
</div>
<div v-if="forecastedRevenue.data" class="border rounded-md min-h-80">
<AxisChart :config="forecastedRevenue.data" />
</div>
<div v-if="funnelConversion.data" class="border rounded-md min-h-80">
<AxisChart :config="funnelConversion.data" />
</div>
<div v-if="lostDealReasons.data" class="border rounded-md">
<AxisChart :config="lostDealReasons.data" />
</div>
<div v-if="dealsByTerritory.data" class="border rounded-md">
<AxisChart :config="dealsByTerritory.data" />
</div>
<div v-if="dealsBySalesperson.data" class="border rounded-md">
<AxisChart :config="dealsBySalesperson.data" />
</div>
<div v-if="dealsByStage.data" class="border rounded-md">
<DonutChart :config="dealsByStage.data" />
</div>
<div v-if="leadsBySource.data" class="border rounded-md">
<DonutChart :config="leadsBySource.data" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
<script setup lang="ts">
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 { getLastXDays, formatter, formatRange } from '@/utils/dashboard'
import {
AxisChart,
DonutChart,
NumberChart,
usePageMeta,
createResource,
DateRangePicker,
Dropdown,
Tooltip,
} from 'frappe-ui'
import { ref, reactive, computed } from 'vue'
const { users, getUser, isManager, isAdmin } = usersStore()
const showDatePicker = ref(false)
const datePickerRef = ref(null)
const preset = ref('Last 30 Days')
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?.()
reload()
}
function reload() {
numberCards.reload()
salesTrend.reload()
funnelConversion.reload()
dealsBySalesperson.reload()
dealsByTerritory.reload()
lostDealReasons.reload()
forecastedRevenue.reload()
dealsByStage.reload()
leadsBySource.reload()
}
const options = computed(() => [
{
group: 'Presets',
hideLabel: true,
items: [
{
label: 'Last 7 Days',
onClick: () => {
preset.value = 'Last 7 Days'
filters.period = getLastXDays(7)
reload()
},
},
{
label: 'Last 30 Days',
onClick: () => {
preset.value = 'Last 30 Days'
filters.period = getLastXDays(30)
reload()
},
},
{
label: 'Last 60 Days',
onClick: () => {
preset.value = 'Last 60 Days'
filters.period = getLastXDays(60)
reload()
},
},
{
label: 'Last 90 Days',
onClick: () => {
preset.value = 'Last 90 Days'
filters.period = getLastXDays(90)
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 numberCards = createResource({
url: 'crm.api.dashboard.get_number_card_data',
cache: ['Analytics', 'NumberCards'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
})
const salesTrend = createResource({
url: 'crm.api.dashboard.get_sales_trend_data',
cache: ['Analytics', 'SalesTrend'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Sales Trend'),
subtitle: __('Daily performance of leads, deals, and wins'),
xAxis: {
title: __('Date'),
key: 'date',
type: 'time' as const,
timeGrain: 'day' as const,
},
yAxis: {
title: __('Count'),
},
series: [
{ name: 'leads', type: 'line' as const, showDataPoints: true },
{ name: 'deals', type: 'line' as const, showDataPoints: true },
{ name: 'won_deals', type: 'line' as const, showDataPoints: true },
],
}
},
})
const funnelConversion = createResource({
url: 'crm.api.dashboard.get_funnel_conversion_data',
cache: ['Analytics', 'FunnelConversion'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Funnel Conversion'),
subtitle: __('Lead to deal conversion pipeline'),
xAxis: {
title: __('Stage'),
key: 'stage',
type: 'category' as const,
},
yAxis: {
title: __('Count'),
},
swapXY: true,
series: [
{
name: 'count',
type: 'bar' as const,
echartOptions: {
colorBy: 'data',
},
},
],
}
},
})
const dealsBySalesperson = createResource({
url: 'crm.api.dashboard.get_deals_by_salesperson',
cache: ['Analytics', 'DealsBySalesperson'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Deals by Salesperson'),
subtitle: 'Number of deals and total value per salesperson',
xAxis: {
title: __('Salesperson'),
key: 'salesperson',
type: 'category' as const,
},
yAxis: {
title: __('Number of Deals'),
},
y2Axis: {
title: __('Deal Value') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'deals', type: 'bar' as const },
{
name: 'value',
type: 'line' as const,
showDataPoints: true,
axis: 'y2' as const,
},
],
}
},
})
const dealsByTerritory = createResource({
url: 'crm.api.dashboard.get_deals_by_territory',
cache: ['Analytics', 'DealsByTerritory'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Deals by Territory'),
subtitle: __('Geographic distribution of deals and revenue'),
xAxis: {
title: __('Territory'),
key: 'territory',
type: 'category' as const,
},
yAxis: {
title: __('Number of Deals'),
},
y2Axis: {
title: __('Deal Value') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'deals', type: 'bar' as const },
{
name: 'value',
type: 'line' as const,
showDataPoints: true,
axis: 'y2' as const,
},
],
}
},
})
const lostDealReasons = createResource({
url: 'crm.api.dashboard.get_lost_deal_reasons',
cache: ['Analytics', 'LostDealReasons'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Lost Deal Reasons'),
subtitle: __('Common reasons for losing deals'),
xAxis: {
title: __('Reason'),
key: 'reason',
type: 'category' as const,
},
yAxis: {
title: __('Count'),
},
swapXY: true,
series: [{ name: 'count', type: 'bar' as const }],
}
},
})
const forecastedRevenue = createResource({
url: 'crm.api.dashboard.get_forecasted_revenue',
cache: ['Analytics', 'ForecastedRevenue'],
makeParams() {
return { user: filters.user }
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Revenue Forecast'),
subtitle: __('Projected vs actual revenue based on deal probability'),
xAxis: {
title: __('Month'),
key: 'month',
type: 'time' as const,
timeGrain: 'month' as const,
},
yAxis: {
title: __('Revenue') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'forecasted', type: 'line' as const, showDataPoints: true },
{ name: 'actual', type: 'line' as const, showDataPoints: true },
],
}
},
})
const dealsByStage = createResource({
url: 'crm.api.dashboard.get_deals_by_stage',
cache: ['Analytics', 'DealsByStage'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Deals by Stage'),
subtitle: __('Current pipeline distribution'),
categoryColumn: 'stage',
valueColumn: 'count',
}
},
})
const leadsBySource = createResource({
url: 'crm.api.dashboard.get_leads_by_source',
cache: ['Analytics', 'LeadsBySource'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Leads by Source'),
subtitle: __('Lead generation channel analysis'),
categoryColumn: 'source',
valueColumn: 'count',
}
},
})
usePageMeta(() => {
return { title: __('CRM Dashboard') }
})
</script>

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

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