diff --git a/crm/api/dashboard.py b/crm/api/dashboard.py index da3eaada..5a9c56b8 100644 --- a/crm/api/dashboard.py +++ b/crm/api/dashboard.py @@ -1,14 +1,54 @@ +import json + import frappe from frappe import _ +from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard 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=""): +def get_dashboard(from_date="", to_date="", user=""): """ - Get number card data for the dashboard. + Get the dashboard data for the CRM 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()) + + roles = frappe.get_roles(frappe.session.user) + is_sales_user = "Sales User" in roles and "Sales Manager" not in roles and "System Manager" not in roles + if is_sales_user and not user: + user = frappe.session.user + + dashboard = frappe.db.exists("CRM Dashboard", "Manager Dashboard") + + layout = [] + + if not dashboard: + layout = json.loads(create_default_manager_dashboard()) + frappe.db.commit() + else: + layout = json.loads(frappe.db.get_value("CRM Dashboard", "Manager Dashboard", "layout") or "[]") + + for l in layout: + method_name = f"get_{l['name']}" + if hasattr(frappe.get_attr("crm.api.dashboard"), method_name): + method = getattr(frappe.get_attr("crm.api.dashboard"), method_name) + l["data"] = method(from_date, to_date, user) + else: + l["data"] = None + + return layout + + +@frappe.whitelist() +@sales_user_only +def get_chart(name, type, from_date="", to_date="", user=""): + """ + Get number chart 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()) @@ -19,31 +59,19 @@ def get_number_card_data(from_date="", to_date="", user="", lead_conds="", deal_ if is_sales_user and not user: user = frappe.session.user - lead_count = get_lead_count(from_date, to_date, user, lead_conds) - ongoing_deal_count = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["count"] - average_ongoing_deal_value = get_ongoing_deal_count(from_date, to_date, user, deal_conds)["average"] - won_deal_count = get_won_deal_count(from_date, to_date, user, deal_conds)["count"] - average_won_deal_value = get_won_deal_count(from_date, to_date, user, deal_conds)["average"] - average_deal_value = get_average_deal_value(from_date, to_date, user, deal_conds) - average_time_to_close_a_lead = get_average_time_to_close(from_date, to_date, user, deal_conds)["lead"] - average_time_to_close_a_deal = get_average_time_to_close(from_date, to_date, user, deal_conds)["deal"] - - return [ - lead_count, - ongoing_deal_count, - average_ongoing_deal_value, - won_deal_count, - average_won_deal_value, - average_deal_value, - average_time_to_close_a_lead, - average_time_to_close_a_deal, - ] + method_name = f"get_{name}" + if hasattr(frappe.get_attr("crm.api.dashboard"), method_name): + method = getattr(frappe.get_attr("crm.api.dashboard"), method_name) + return method(from_date, to_date, user) + else: + return {"error": _("Invalid chart name")} -def get_lead_count(from_date, to_date, user="", conds="", return_result=False): +def get_total_leads(from_date, to_date, user=""): """ Get lead count for the dashboard. """ + conds = "" diff = frappe.utils.date_diff(to_date, from_date) if diff == 0: @@ -78,9 +106,6 @@ def get_lead_count(from_date, to_date, user="", conds="", return_result=False): 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 @@ -90,17 +115,18 @@ def get_lead_count(from_date, to_date, user="", conds="", return_result=False): return { "title": _("Total leads"), + "tooltip": _("Total number of leads"), "value": current_month_leads, "delta": delta_in_percentage, "deltaSuffix": "%", - "tooltip": _("Total number of leads"), } -def get_ongoing_deal_count(from_date, to_date, user="", conds="", return_result=False): +def get_ongoing_deals(from_date, to_date, user=""): """ Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals. """ + conds = "" diff = frappe.utils.date_diff(to_date, from_date) if diff == 0: @@ -126,8 +152,50 @@ def get_ongoing_deal_count(from_date, to_date, user="", conds="", return_result= {conds} THEN d.name ELSE NULL - END) as prev_month_deals, + END) as prev_month_deals + FROM `tabCRM Deal` d + JOIN `tabCRM Deal Status` s ON d.status = s.name + """, + { + "from_date": from_date, + "to_date": to_date, + "prev_from_date": frappe.utils.add_days(from_date, -diff), + }, + as_dict=1, + ) + 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": _("Ongoing deals"), + "tooltip": _("Total number of non won/lost deals"), + "value": current_month_deals, + "delta": delta_in_percentage, + "deltaSuffix": "%", + } + + +def get_average_ongoing_deal_value(from_date, to_date, user=""): + """ + Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals. + """ + conds = "" + + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + if user: + conds += f" AND d.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 s.type NOT IN ('Won', 'Lost') @@ -154,39 +222,21 @@ def get_ongoing_deal_count(from_date, to_date, user="", conds="", return_result= 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 current_month_avg_value = result[0].current_month_avg_value or 0 prev_month_avg_value = result[0].prev_month_avg_value or 0 - delta_in_percentage = ( - (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 - ) avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0 return { - "count": { - "title": _("Ongoing deals"), - "value": current_month_deals, - "delta": delta_in_percentage, - "deltaSuffix": "%", - "tooltip": _("Total number of ongoing deals"), - }, - "average": { - "title": _("Avg ongoing deal value"), - "value": current_month_avg_value, - "delta": avg_value_delta, - "prefix": get_base_currency_symbol(), - # "suffix": "K", - "tooltip": _("Average deal value of ongoing deals"), - }, + "title": _("Avg. ongoing deal value"), + "tooltip": _("Average deal value of non won/lost deals"), + "value": current_month_avg_value, + "delta": avg_value_delta, + "prefix": get_base_currency_symbol(), } -def get_won_deal_count(from_date, to_date, user="", conds="", return_result=False): +def get_won_deals(from_date, to_date, user=""): """ Get won deal count for the dashboard, and also calculate average deal value for won deals. """ @@ -195,6 +245,8 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals if diff == 0: diff = 1 + conds = "" + if user: conds += f" AND d.deal_owner = '{user}'" @@ -215,8 +267,51 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals {conds} THEN d.name ELSE NULL - END) as prev_month_deals, + END) as prev_month_deals + FROM `tabCRM Deal` d + JOIN `tabCRM Deal Status` s ON d.status = s.name + """, + { + "from_date": from_date, + "to_date": to_date, + "prev_from_date": frappe.utils.add_days(from_date, -diff), + }, + as_dict=1, + ) + 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"), + "tooltip": _("Total number of won deals based on its closure date"), + "value": current_month_deals, + "delta": delta_in_percentage, + "deltaSuffix": "%", + } + + +def get_average_won_deal_value(from_date, to_date, user=""): + """ + Get won deal count for the dashboard, and also calculate average deal value for won deals. + """ + + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + conds = "" + + if user: + conds += f" AND d.deal_owner = '{user}'" + + result = frappe.db.sql( + f""" + SELECT AVG(CASE WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) AND s.type = 'Won' @@ -243,39 +338,21 @@ def get_won_deal_count(from_date, to_date, user="", conds="", return_result=Fals 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 current_month_avg_value = result[0].current_month_avg_value or 0 prev_month_avg_value = result[0].prev_month_avg_value or 0 - delta_in_percentage = ( - (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 - ) avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0 return { - "count": { - "title": _("Won deals"), - "value": current_month_deals, - "delta": delta_in_percentage, - "deltaSuffix": "%", - "tooltip": _("Total number of won deals based on its closure date"), - }, - "average": { - "title": _("Avg won deal value"), - "value": current_month_avg_value, - "delta": avg_value_delta, - "prefix": get_base_currency_symbol(), - # "suffix": "K", - "tooltip": _("Average deal value of won deals"), - }, + "title": _("Avg. won deal value"), + "tooltip": _("Average deal value of won deals"), + "value": current_month_avg_value, + "delta": avg_value_delta, + "prefix": get_base_currency_symbol(), } -def get_average_deal_value(from_date, to_date, user="", conds="", return_result=False): +def get_average_deal_value(from_date, to_date, user=""): """ Get average deal value for the dashboard. """ @@ -284,6 +361,8 @@ def get_average_deal_value(from_date, to_date, user="", conds="", return_result= if diff == 0: diff = 1 + conds = "" + if user: conds += f" AND d.deal_owner = '{user}'" @@ -322,28 +401,26 @@ def get_average_deal_value(from_date, to_date, user="", conds="", return_result= delta = current_month_avg - prev_month_avg if prev_month_avg else 0 return { - "title": _("Avg deal value"), - "value": current_month_avg, + "title": _("Avg. deal value"), "tooltip": _("Average deal value of ongoing & won deals"), + "value": current_month_avg, "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): +def get_average_time_to_close_a_lead(from_date, to_date, user=""): """ Get average time to close deals for the dashboard. - Returns both: - - Average time from lead creation to deal closure - - Average time from deal creation to deal closure """ diff = frappe.utils.date_diff(to_date, from_date) if diff == 0: diff = 1 + conds = "" + if user: conds += f" AND d.deal_owner = '{user}'" @@ -356,7 +433,57 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu AVG(CASE WHEN d.closed_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_date) END) as current_avg_lead, AVG(CASE WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(prev_to_date)s - THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_date) END) as prev_avg_lead, + THEN TIMESTAMPDIFF(DAY, COALESCE(l.creation, d.creation), d.closed_date) END) as prev_avg_lead + FROM `tabCRM Deal` AS d + JOIN `tabCRM Deal Status` s ON d.status = s.name + LEFT JOIN `tabCRM Lead` l ON d.lead = l.name + WHERE d.closed_date IS NOT NULL AND s.type = 'Won' + {conds} + """, + { + "from_date": from_date, + "to_date": to_date, + "prev_from_date": prev_from_date, + "prev_to_date": prev_to_date, + }, + as_dict=1, + ) + + current_avg_lead = result[0].current_avg_lead or 0 + prev_avg_lead = result[0].prev_avg_lead or 0 + delta_lead = current_avg_lead - prev_avg_lead if prev_avg_lead else 0 + + return { + "title": _("Avg. time to close a lead"), + "tooltip": _("Average time taken from lead creation to deal closure"), + "value": current_avg_lead, + "suffix": " days", + "delta": delta_lead, + "deltaSuffix": " days", + "negativeIsBetter": True, + } + + +def get_average_time_to_close_a_deal(from_date, to_date, user=""): + """ + Get average time to close deals for the dashboard. + """ + + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + conds = "" + + 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_date >= %(from_date)s AND d.closed_date < DATE_ADD(%(to_date)s, INTERVAL 1 DAY) THEN TIMESTAMPDIFF(DAY, d.creation, d.closed_date) END) as current_avg_deal, AVG(CASE WHEN d.closed_date >= %(prev_from_date)s AND d.closed_date < %(prev_to_date)s @@ -376,41 +503,22 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu as_dict=1, ) - if return_result: - return result - - current_avg_lead = result[0].current_avg_lead or 0 - prev_avg_lead = result[0].prev_avg_lead or 0 - delta_lead = current_avg_lead - prev_avg_lead if prev_avg_lead else 0 - current_avg_deal = result[0].current_avg_deal or 0 prev_avg_deal = result[0].prev_avg_deal or 0 delta_deal = current_avg_deal - prev_avg_deal if prev_avg_deal else 0 return { - "lead": { - "title": _("Avg time to close a lead"), - "value": current_avg_lead, - "tooltip": _("Average time taken from lead creation to deal closure"), - "suffix": " days", - "delta": delta_lead, - "deltaSuffix": " days", - "negativeIsBetter": True, - }, - "deal": { - "title": _("Avg time to close a deal"), - "value": current_avg_deal, - "tooltip": _("Average time taken from deal creation to deal closure"), - "suffix": " days", - "delta": delta_deal, - "deltaSuffix": " days", - "negativeIsBetter": True, - }, + "title": _("Avg. time to close a deal"), + "tooltip": _("Average time taken from deal creation to deal closure"), + "value": current_avg_deal, + "suffix": " days", + "delta": delta_deal, + "deltaSuffix": " days", + "negativeIsBetter": True, } -@frappe.whitelist() -def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""): +def get_sales_trend(from_date="", to_date="", user=""): """ Get sales trend data for the dashboard. [ @@ -420,6 +528,9 @@ def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_ ] """ + lead_conds = "" + deal_conds = "" + 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()) @@ -466,7 +577,7 @@ def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_ as_dict=True, ) - return [ + sales_trend = [ { "date": frappe.utils.get_datetime(row.date).strftime("%Y-%m-%d"), "leads": row.leads or 0, @@ -476,128 +587,28 @@ def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_ 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(), + "data": sales_trend, + "title": _("Sales trend"), + "subtitle": _("Daily performance of leads, deals, and wins"), + "xAxis": { + "title": _("Date"), + "key": "date", + "type": "time", + "timeGrain": "day", + }, + "yAxis": { + "title": _("Count"), + }, + "series": [ + {"name": "leads", "type": "line", "showDataPoints": True}, + {"name": "deals", "type": "line", "showDataPoints": True}, + {"name": "won_deals", "type": "line", "showDataPoints": True}, + ], } -@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 - JOIN `tabCRM Deal Status` s ON d.status = s.name - WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND s.type = '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=""): +def get_forecasted_revenue(from_date="", to_date="", user=""): """ Get forecasted revenue for the dashboard. [ @@ -608,6 +619,7 @@ def get_forecasted_revenue(user="", deal_conds=""): ... ] """ + deal_conds = "" if user: deal_conds += f" AND d.deal_owner = '{user}'" @@ -637,8 +649,6 @@ def get_forecasted_revenue(user="", deal_conds=""): """, as_dict=True, ) - if not result: - return [] for row in result: row["month"] = frappe.utils.get_datetime(row["month"]).strftime("%Y-%m-01") @@ -647,12 +657,25 @@ def get_forecasted_revenue(user="", deal_conds=""): return { "data": result or [], - "currency_symbol": get_base_currency_symbol(), + "title": _("Forecasted revenue"), + "subtitle": _("Projected vs actual revenue based on deal probability"), + "xAxis": { + "title": _("Month"), + "key": "month", + "type": "time", + "timeGrain": "month", + }, + "yAxis": { + "title": _("Revenue") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "forecasted", "type": "line", "showDataPoints": True}, + {"name": "actual", "type": "line", "showDataPoints": True}, + ], } -@frappe.whitelist() -def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""): +def get_funnel_conversion(from_date="", to_date="", user=""): """ Get funnel conversion data for the dashboard. [ @@ -664,6 +687,8 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="", ... ] """ + lead_conds = "" + deal_conds = "" if not from_date or not to_date: from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) @@ -692,11 +717,32 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="", result += get_deal_status_change_counts(from_date, to_date, deal_conds) - return result or [] + return { + "data": result or [], + "title": _("Funnel conversion"), + "subtitle": _("Lead to deal conversion pipeline"), + "xAxis": { + "title": _("Stage"), + "key": "stage", + "type": "category", + }, + "yAxis": { + "title": _("Count"), + }, + "swapXY": True, + "series": [ + { + "name": "count", + "type": "bar", + "echartOptions": { + "colorBy": "data", + }, + }, + ], + } -@frappe.whitelist() -def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""): +def get_deals_by_stage_axis(from_date="", to_date="", user=""): """ Get deal data by stage for the dashboard. [ @@ -705,6 +751,57 @@ def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""): ... ] """ + deal_conds = "" + + 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, + s.type AS status_type + FROM `tabCRM Deal` AS d + JOIN `tabCRM Deal Status` s ON d.status = s.name + WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND s.type NOT IN ('Lost') + {deal_conds} + GROUP BY d.status + ORDER BY count DESC + """, + {"from": from_date, "to": to_date}, + as_dict=True, + ) + + return { + "data": result or [], + "title": _("Deals by ongoing & won stage"), + "xAxis": { + "title": _("Stage"), + "key": "stage", + "type": "category", + }, + "yAxis": {"title": _("Count")}, + "series": [ + {"name": "count", "type": "bar"}, + ], + } + + +def get_deals_by_stage_donut(from_date="", to_date="", user=""): + """ + Get deal data by stage for the dashboard. + [ + { stage: 'Prospecting', count: 120 }, + { stage: 'Negotiation', count: 45 }, + ... + ] + """ + deal_conds = "" if not from_date or not to_date: from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) @@ -730,11 +827,70 @@ def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""): as_dict=True, ) - return result or [] + return { + "data": result or [], + "title": _("Deals by stage"), + "subtitle": _("Current pipeline distribution"), + "categoryColumn": "stage", + "valueColumn": "count", + } -@frappe.whitelist() -def get_leads_by_source(from_date="", to_date="", user="", lead_conds=""): +def get_lost_deal_reasons(from_date="", to_date="", user=""): + """ + Get lost deal reasons for the dashboard. + [ + { reason: 'Price too high', count: 20 }, + { reason: 'Competitor won', count: 15 }, + ... + ] + """ + + deal_conds = "" + + 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 + JOIN `tabCRM Deal Status` s ON d.status = s.name + WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s AND s.type = '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 { + "data": result or [], + "title": _("Lost deal reasons"), + "subtitle": _("Common reasons for losing deals"), + "xAxis": { + "title": _("Reason"), + "key": "reason", + "type": "category", + }, + "yAxis": { + "title": _("Count"), + }, + "series": [ + {"name": "count", "type": "bar"}, + ], + } + + +def get_leads_by_source(from_date="", to_date="", user=""): """ Get lead data by source for the dashboard. [ @@ -743,6 +899,7 @@ def get_leads_by_source(from_date="", to_date="", user="", lead_conds=""): ... ] """ + lead_conds = "" if not from_date or not to_date: from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) @@ -766,7 +923,168 @@ def get_leads_by_source(from_date="", to_date="", user="", lead_conds=""): as_dict=True, ) - return result or [] + return { + "data": result or [], + "title": _("Leads by source"), + "subtitle": _("Lead generation channel analysis"), + "categoryColumn": "source", + "valueColumn": "count", + } + + +def get_deals_by_source(from_date="", to_date="", user=""): + """ + Get deal data by source for the dashboard. + [ + { source: 'Website', count: 120 }, + { source: 'Referral', count: 45 }, + ... + ] + """ + deal_conds = "" + + 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 deal_owner = '{user}'" + + result = frappe.db.sql( + f""" + SELECT + IFNULL(source, 'Empty') AS source, + COUNT(*) AS count + FROM `tabCRM Deal` + WHERE DATE(creation) BETWEEN %(from)s AND %(to)s + {deal_conds} + GROUP BY source + ORDER BY count DESC + """, + {"from": from_date, "to": to_date}, + as_dict=True, + ) + + return { + "data": result or [], + "title": _("Deals by source"), + "subtitle": _("Deal generation channel analysis"), + "categoryColumn": "source", + "valueColumn": "count", + } + + +def get_deals_by_territory(from_date="", to_date="", user=""): + """ + Get deal data by territory for the dashboard. + [ + { territory: 'North America', deals: 45, value: 2300000 }, + { territory: 'Europe', deals: 30, value: 1500000 }, + ... + ] + """ + deal_conds = "" + + 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 [], + "title": _("Deals by territory"), + "subtitle": _("Geographic distribution of deals and revenue"), + "xAxis": { + "title": _("Territory"), + "key": "territory", + "type": "category", + }, + "yAxis": { + "title": _("Number of deals"), + }, + "y2Axis": { + "title": _("Deal value") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "deals", "type": "bar"}, + {"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"}, + ], + } + + +def get_deals_by_salesperson(from_date="", to_date="", user=""): + """ + Get deal data by salesperson for the dashboard. + [ + { salesperson: 'John Smith', deals: 45, value: 2300000 }, + { salesperson: 'Jane Doe', deals: 30, value: 1500000 }, + ... + ] + """ + deal_conds = "" + + 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 [], + "title": _("Deals by salesperson"), + "subtitle": _("Number of deals and total value per salesperson"), + "xAxis": { + "title": _("Salesperson"), + "key": "salesperson", + "type": "category", + }, + "yAxis": { + "title": _("Number of deals"), + }, + "y2Axis": { + "title": _("Deal value") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "deals", "type": "bar"}, + {"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"}, + ], + } def get_base_currency_symbol(): diff --git a/crm/fcrm/doctype/crm_dashboard/__init__.py b/crm/fcrm/doctype/crm_dashboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js new file mode 100644 index 00000000..9aee84c7 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js @@ -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) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json new file mode 100644 index 00000000..11dcdec4 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json @@ -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" +} diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py new file mode 100644 index 00000000..0f6ffaa3 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py @@ -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 diff --git a/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py b/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py new file mode 100644 index 00000000..7e915613 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py @@ -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 diff --git a/crm/install.py b/crm/install.py index 1b401db2..473ba5f6 100644 --- a/crm/install.py +++ b/crm/install.py @@ -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() diff --git a/crm/patches/v1_0/update_deal_status_type.py b/crm/patches/v1_0/update_deal_status_type.py index 230f3776..d3e4084c 100644 --- a/crm/patches/v1_0/update_deal_status_type.py +++ b/crm/patches/v1_0/update_deal_status_type.py @@ -27,7 +27,7 @@ def execute(): ] for status in deal_statuses: - if status.type is None or status.type == "": + 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: diff --git a/frappe-ui b/frappe-ui index 424288f7..b295b54a 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179 +Subproject commit b295b54aaa3a2a22f455df819d87433dc6b9ff6a diff --git a/frontend/components.d.ts b/frontend/components.d.ts index cff4e482..c1dd22b6 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] @@ -62,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'] @@ -99,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'] @@ -167,11 +168,9 @@ declare module 'vue' { 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'] - LucideSearch: typeof import('~icons/lucide/search')['default'] - LucideX: typeof import('~icons/lucide/x')['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'] @@ -205,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'] diff --git a/frontend/package.json b/frontend/package.json index 7672e8df..ec932138 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/Dashboard/AddChartModal.vue b/frontend/src/components/Dashboard/AddChartModal.vue new file mode 100644 index 00000000..31c2931c --- /dev/null +++ b/frontend/src/components/Dashboard/AddChartModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/frontend/src/components/Dashboard/DashboardGrid.vue b/frontend/src/components/Dashboard/DashboardGrid.vue new file mode 100644 index 00000000..ca984a0c --- /dev/null +++ b/frontend/src/components/Dashboard/DashboardGrid.vue @@ -0,0 +1,62 @@ + + diff --git a/frontend/src/components/Dashboard/DashboardItem.vue b/frontend/src/components/Dashboard/DashboardItem.vue new file mode 100644 index 00000000..376914e2 --- /dev/null +++ b/frontend/src/components/Dashboard/DashboardItem.vue @@ -0,0 +1,49 @@ + + diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue index e118a694..0cb7fbd9 100644 --- a/frontend/src/pages/Dashboard.vue +++ b/frontend/src/pages/Dashboard.vue @@ -4,9 +4,44 @@ + -
+
-
-
-
- - - -
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
+
+
+