Merge pull request #1021 from frappe/mergify/bp/main-hotfix/pr-979
This commit is contained in:
commit
3a27fb91c1
695
crm/api/dashboard.py
Normal file
695
crm/api/dashboard.py
Normal 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 ""
|
||||
@ -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 = []
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
|
||||
6
frontend/components.d.ts
vendored
6
frontend/components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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 }">
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
160
frontend/src/components/Settings/General/GeneralSettings.vue
Normal file
160
frontend/src/components/Settings/General/GeneralSettings.vue
Normal 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>
|
||||
@ -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>
|
||||
60
frontend/src/components/Settings/General/HomeActions.vue
Normal file
60
frontend/src/components/Settings/General/HomeActions.vue
Normal 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>
|
||||
@ -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(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'])
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
28
frontend/src/utils/dashboard.ts
Normal file
28
frontend/src/utils/dashboard.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user