diff --git a/crm/api/dashboard.py b/crm/api/dashboard.py
new file mode 100644
index 00000000..295d6e56
--- /dev/null
+++ b/crm/api/dashboard.py
@@ -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 ""
diff --git a/crm/api/session.py b/crm/api/session.py
index add14c37..b45ac43d 100644
--- a/crm/api/session.py
+++ b/crm/api/session.py
@@ -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 = []
diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json
index 1f9f604f..14880fbd 100644
--- a/crm/fcrm/doctype/crm_deal/crm_deal.json
+++ b/crm/fcrm/doctype/crm_deal/crm_deal.json
@@ -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",
diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py
index 6e056ffe..b82f16db 100644
--- a/crm/fcrm/doctype/crm_deal/crm_deal.py
+++ b/crm/fcrm/doctype/crm_deal/crm_deal.py
@@ -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 = [
diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.json b/crm/fcrm/doctype/crm_organization/crm_organization.json
index 34252d1c..53b5c826 100644
--- a/crm/fcrm/doctype/crm_organization/crm_organization.json
+++ b/crm/fcrm/doctype/crm_organization/crm_organization.json
@@ -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": []
-}
\ No newline at end of file
+}
diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.py b/crm/fcrm/doctype/crm_organization/crm_organization.py
index 471d0f51..1cdf186c 100644
--- a/crm/fcrm/doctype/crm_organization/crm_organization.py
+++ b/crm/fcrm/doctype/crm_organization/crm_organization.py
@@ -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}
diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
index e679b023..00907793 100644
--- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
+++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
@@ -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",
diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py
index 1460c265..0b4e2932 100644
--- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py
+++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py
@@ -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")]
diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py
index 9b954859..6f7bbad0 100644
--- a/crm/utils/__init__.py
+++ b/crm/utils/__init__.py
@@ -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
diff --git a/frontend/components.d.ts b/frontend/components.d.ts
index 63df320c..cff4e482 100644
--- a/frontend/components.d.ts
+++ b/frontend/components.d.ts
@@ -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']
diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue
index 3cb2d6cd..633f2b2e 100644
--- a/frontend/src/components/Controls/Link.vue
+++ b/frontend/src/components/Controls/Link.vue
@@ -10,6 +10,8 @@
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
+ :disabled="attrs.disabled"
+ :placement="attrs.placement"
:filterable="false"
>
diff --git a/frontend/src/components/Layouts/AppSidebar.vue b/frontend/src/components/Layouts/AppSidebar.vue
index e78fa0dc..d1dc3c82 100644
--- a/frontend/src/components/Layouts/AppSidebar.vue
+++ b/frontend/src/components/Layouts/AppSidebar.vue
@@ -140,6 +140,7 @@
diff --git a/frontend/src/components/Settings/General/GeneralSettings.vue b/frontend/src/components/Settings/General/GeneralSettings.vue
new file mode 100644
index 00000000..29523d5c
--- /dev/null
+++ b/frontend/src/components/Settings/General/GeneralSettings.vue
@@ -0,0 +1,160 @@
+
+
+ {{ __('Configure general settings for your CRM') }}
+
+ {{ __('General') }}
+
+