fix: use GridLayout from frappe-ui to display dashboard
This commit is contained in:
parent
e7a2efd14a
commit
160649bf97
@ -6,6 +6,291 @@ from crm.utils import sales_user_only
|
||||
|
||||
@frappe.whitelist()
|
||||
@sales_user_only
|
||||
def get_dashboard_items(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
|
||||
"""
|
||||
Get dashboard items for the CRM dashboard.
|
||||
Returns a list of number cards with lead and deal statistics.
|
||||
"""
|
||||
|
||||
number_cards = get_number_card_data(from_date, to_date, user, lead_conds, deal_conds)
|
||||
sales_trend = get_sales_trend_data(from_date, to_date, user, lead_conds, deal_conds)
|
||||
forecasted_revenue = get_forecasted_revenue(user, deal_conds)
|
||||
funnel_conversion = get_funnel_conversion_data(from_date, to_date, user, lead_conds, deal_conds)
|
||||
deals_by_stage = get_deals_by_stage(from_date, to_date, user, deal_conds)
|
||||
deals_by_stage_axis = (
|
||||
[d for d in deals_by_stage if d.get("status_type") != "Lost"] if deals_by_stage else []
|
||||
)
|
||||
leads_by_source = get_leads_by_source(from_date, to_date, user, lead_conds)
|
||||
deals_by_source = get_deals_by_source(from_date, to_date, user, deal_conds)
|
||||
deals_by_territory = get_deals_by_territory(from_date, to_date, user, deal_conds)
|
||||
deals_by_salesperson = get_deals_by_salesperson(from_date, to_date, user, deal_conds)
|
||||
lost_deal_reasons = get_lost_deal_reasons(from_date, to_date, user, deal_conds)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": "total-leads",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Total number of leads"),
|
||||
"data": number_cards.get("total_leads"),
|
||||
"layout": {"x": 0, "y": 0, "w": 4, "h": 2, "i": "0"},
|
||||
},
|
||||
{
|
||||
"id": "ongoing-deals",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Total number of ongoing deals"),
|
||||
"data": number_cards.get("ongoing_deals"),
|
||||
"layout": {"x": 4, "y": 0, "w": 4, "h": 2, "i": "1"},
|
||||
},
|
||||
{
|
||||
"id": "average-ongoing-deal-value",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Average value of ongoing deals"),
|
||||
"data": number_cards.get("average_ongoing_deal_value"),
|
||||
"layout": {"x": 8, "y": 0, "w": 4, "h": 2, "i": "2"},
|
||||
},
|
||||
{
|
||||
"id": "won-deals",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Total number of won deals"),
|
||||
"data": number_cards.get("won_deal_count"),
|
||||
"layout": {"x": 12, "y": 0, "w": 4, "h": 2, "i": "3"},
|
||||
},
|
||||
{
|
||||
"id": "average-won-deal-value",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Average value of won deals"),
|
||||
"data": number_cards.get("average_won_deal_value"),
|
||||
"layout": {"x": 16, "y": 0, "w": 4, "h": 2, "i": "4"},
|
||||
},
|
||||
{
|
||||
"id": "average-deal-value",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Average deal value of ongoing and won deals"),
|
||||
"data": number_cards.get("average_deal_value"),
|
||||
"layout": {"x": 0, "y": 2, "w": 4, "h": 2, "i": "5"},
|
||||
},
|
||||
{
|
||||
"id": "average-time-to-close-a-lead",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Average time taken to close a lead"),
|
||||
"data": number_cards.get("average_time_to_close_a_lead"),
|
||||
"layout": {"x": 4, "y": 4, "w": 4, "h": 2, "i": "6"},
|
||||
},
|
||||
{
|
||||
"id": "average-time-to-close-a-deal",
|
||||
"type": "number-card",
|
||||
"tooltip": _("Average time taken to close a deal"),
|
||||
"data": number_cards.get("average_time_to_close_a_deal"),
|
||||
"layout": {"x": 8, "y": 4, "w": 4, "h": 2, "i": "7"},
|
||||
},
|
||||
{
|
||||
"id": "blank-card-1",
|
||||
"type": "blank-card",
|
||||
"layout": {"x": 12, "y": 4, "w": 8, "h": 2, "i": "8"},
|
||||
},
|
||||
{
|
||||
"id": "sales-trend",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"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},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 0, "y": 6, "w": 10, "h": 7, "i": "9"},
|
||||
},
|
||||
{
|
||||
"id": "forecasted-revenue",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": forecasted_revenue or [],
|
||||
"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},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 10, "y": 6, "w": 10, "h": 7, "i": "10"},
|
||||
},
|
||||
{
|
||||
"id": "funnel-conversion",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": funnel_conversion 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 0, "y": 14, "w": 10, "h": 7, "i": "11"},
|
||||
},
|
||||
{
|
||||
"id": "deals-by-stage-axis",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": deals_by_stage_axis,
|
||||
"title": _("Deals by ongoing & won stage"),
|
||||
"xAxis": {
|
||||
"title": _("Stage"),
|
||||
"key": "stage",
|
||||
"type": "category",
|
||||
},
|
||||
"yAxis": {"title": _("Count")},
|
||||
"series": [
|
||||
{"name": "count", "type": "bar"},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 10, "y": 14, "w": 10, "h": 7, "i": "12"},
|
||||
},
|
||||
{
|
||||
"id": "deals-by-stage-donut",
|
||||
"type": "donut-card",
|
||||
"data": {
|
||||
"data": deals_by_stage,
|
||||
"title": _("Deals by stage"),
|
||||
"subtitle": _("Current pipeline distribution"),
|
||||
"categoryColumn": "stage",
|
||||
"valueColumn": "count",
|
||||
},
|
||||
"layout": {"x": 0, "y": 22, "w": 10, "h": 7, "i": "13"},
|
||||
},
|
||||
{
|
||||
"id": "lost-deal-reasons",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": lost_deal_reasons,
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 10, "y": 22, "w": 10, "h": 7, "i": "14"},
|
||||
},
|
||||
{
|
||||
"id": "leads-by-source",
|
||||
"type": "donut-card",
|
||||
"data": {
|
||||
"data": leads_by_source,
|
||||
"title": _("Leads by source"),
|
||||
"subtitle": _("Lead generation channel analysis"),
|
||||
"categoryColumn": "source",
|
||||
"valueColumn": "count",
|
||||
},
|
||||
"layout": {"x": 0, "y": 30, "w": 10, "h": 7, "i": "15"},
|
||||
},
|
||||
{
|
||||
"id": "deals-by-source",
|
||||
"type": "donut-card",
|
||||
"data": {
|
||||
"data": deals_by_source,
|
||||
"title": _("Deals by source"),
|
||||
"subtitle": _("Deal generation channel analysis"),
|
||||
"categoryColumn": "source",
|
||||
"valueColumn": "count",
|
||||
},
|
||||
"layout": {"x": 10, "y": 30, "w": 10, "h": 7, "i": "16"},
|
||||
},
|
||||
{
|
||||
"id": "deals-by-territory",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": deals_by_territory,
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 0, "y": 38, "w": 10, "h": 7, "i": "17"},
|
||||
},
|
||||
{
|
||||
"id": "deals-by-salesperson",
|
||||
"type": "axis-card",
|
||||
"data": {
|
||||
"data": deals_by_salesperson,
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"layout": {"x": 10, "y": 38, "w": 10, "h": 7, "i": "18"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_number_card_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
|
||||
"""
|
||||
Get number card data for the dashboard.
|
||||
@ -19,25 +304,16 @@ 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,
|
||||
]
|
||||
return {
|
||||
"total_leads": get_lead_count(from_date, to_date, user, lead_conds),
|
||||
"ongoing_deals": 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"],
|
||||
}
|
||||
|
||||
|
||||
def get_lead_count(from_date, to_date, user="", conds="", return_result=False):
|
||||
@ -93,7 +369,6 @@ def get_lead_count(from_date, to_date, user="", conds="", return_result=False):
|
||||
"value": current_month_leads,
|
||||
"delta": delta_in_percentage,
|
||||
"deltaSuffix": "%",
|
||||
"tooltip": _("Total number of leads"),
|
||||
}
|
||||
|
||||
|
||||
@ -173,7 +448,6 @@ def get_ongoing_deal_count(from_date, to_date, user="", conds="", return_result=
|
||||
"value": current_month_deals,
|
||||
"delta": delta_in_percentage,
|
||||
"deltaSuffix": "%",
|
||||
"tooltip": _("Total number of ongoing deals"),
|
||||
},
|
||||
"average": {
|
||||
"title": _("Avg ongoing deal value"),
|
||||
@ -409,7 +683,6 @@ def get_average_time_to_close(from_date, to_date, user="", conds="", return_resu
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
|
||||
"""
|
||||
Get sales trend data for the dashboard.
|
||||
@ -477,7 +750,6 @@ def get_sales_trend_data(from_date="", to_date="", user="", lead_conds="", deal_
|
||||
]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deals_by_salesperson(from_date="", to_date="", user="", deal_conds=""):
|
||||
"""
|
||||
Get deal data by salesperson for the dashboard.
|
||||
@ -512,13 +784,9 @@ def get_deals_by_salesperson(from_date="", to_date="", user="", deal_conds=""):
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"data": result or [],
|
||||
"currency_symbol": get_base_currency_symbol(),
|
||||
}
|
||||
return result or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deals_by_territory(from_date="", to_date="", user="", deal_conds=""):
|
||||
"""
|
||||
Get deal data by territory for the dashboard.
|
||||
@ -552,13 +820,9 @@ def get_deals_by_territory(from_date="", to_date="", user="", deal_conds=""):
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"data": result or [],
|
||||
"currency_symbol": get_base_currency_symbol(),
|
||||
}
|
||||
return result or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lost_deal_reasons(from_date="", to_date="", user="", deal_conds=""):
|
||||
"""
|
||||
Get lost deal reasons for the dashboard.
|
||||
@ -596,7 +860,6 @@ def get_lost_deal_reasons(from_date="", to_date="", user="", deal_conds=""):
|
||||
return result or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_forecasted_revenue(user="", deal_conds=""):
|
||||
"""
|
||||
Get forecasted revenue for the dashboard.
|
||||
@ -645,13 +908,9 @@ def get_forecasted_revenue(user="", deal_conds=""):
|
||||
row["forecasted"] = row["forecasted"] or ""
|
||||
row["actual"] = row["actual"] or ""
|
||||
|
||||
return {
|
||||
"data": result or [],
|
||||
"currency_symbol": get_base_currency_symbol(),
|
||||
}
|
||||
return result or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="", deal_conds=""):
|
||||
"""
|
||||
Get funnel conversion data for the dashboard.
|
||||
@ -695,7 +954,6 @@ def get_funnel_conversion_data(from_date="", to_date="", user="", lead_conds="",
|
||||
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.
|
||||
@ -733,7 +991,6 @@ def get_deals_by_stage(from_date="", to_date="", user="", deal_conds=""):
|
||||
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.
|
||||
@ -769,6 +1026,41 @@ def get_leads_by_source(from_date="", to_date="", user="", lead_conds=""):
|
||||
return result or []
|
||||
|
||||
|
||||
def get_deals_by_source(from_date="", to_date="", user="", deal_conds=""):
|
||||
"""
|
||||
Get deal 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:
|
||||
deal_conds += f" AND lead_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 result or []
|
||||
|
||||
|
||||
def get_base_currency_symbol():
|
||||
"""
|
||||
Get the base currency symbol from the system settings.
|
||||
|
||||
13
frontend/components.d.ts
vendored
13
frontend/components.d.ts
vendored
@ -62,7 +62,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 +101,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 +167,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 +203,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']
|
||||
|
||||
50
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
50
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<GridLayout
|
||||
v-if="items.length > 0"
|
||||
class="h-fit w-full"
|
||||
:class="[editing ? 'mb-[20rem] !select-none' : '']"
|
||||
:cols="20"
|
||||
:disabled="!editing"
|
||||
:modelValue="items.map((item) => item.layout)"
|
||||
@update:modelValue="
|
||||
(newLayout) => {
|
||||
items.forEach((item, idx) => {
|
||||
item.layout = newLayout[idx]
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ index }">
|
||||
<div class="group relative flex h-full w-full p-2 text-ink-gray-8">
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
:class="
|
||||
editing
|
||||
? 'pointer-events-none [&>div:first-child]:rounded [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<DashboardItem
|
||||
:index="index"
|
||||
:item="items[index]"
|
||||
:editing="editing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GridLayout>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { GridLayout } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const items = defineModel()
|
||||
</script>
|
||||
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div
|
||||
v-if="item.type == 'number-card'"
|
||||
class="rounded shadow overflow-hidden cursor-pointer"
|
||||
>
|
||||
<Tooltip :text="item.tooltip">
|
||||
<NumberChart v-if="item.data" :key="index" :config="item.data" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'blank-card'"
|
||||
class="rounded bg-surface-white h-full overflow-hidden text-ink-gray-5 flex items-center justify-center"
|
||||
:class="editing ? 'border border-dashed border-outline-gray-2' : ''"
|
||||
>
|
||||
{{ editing ? __('Blank card') : '' }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'axis-card'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow"
|
||||
>
|
||||
<AxisChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'donut-card'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow overflow-hidden"
|
||||
>
|
||||
<DonutChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { AxisChart, DonutChart, NumberChart, Tooltip } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -4,9 +4,32 @@
|
||||
<template #left-header>
|
||||
<ViewBreadcrumbs routeName="Dashboard" />
|
||||
</template>
|
||||
<template #right-header>
|
||||
<Button
|
||||
v-if="!editing"
|
||||
:label="__('Refresh')"
|
||||
@click="dashboardItems.reload"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideRefreshCcw class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="!editing" :label="__('Edit')" @click="editing = true">
|
||||
<template #prefix>
|
||||
<LucidePenLine class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button v-if="editing" :label="__('Cancel')" @click="cancel" />
|
||||
<Button
|
||||
v-if="editing"
|
||||
variant="solid"
|
||||
:label="__('Save')"
|
||||
@click="save"
|
||||
/>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
|
||||
<div class="p-5 pb-3 flex items-center gap-4">
|
||||
<div class="p-5 pb-0 flex items-center gap-4">
|
||||
<Dropdown
|
||||
v-if="!showDatePicker"
|
||||
:options="options"
|
||||
@ -83,58 +106,20 @@
|
||||
</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="dealsByStage.data" class="border rounded-md">
|
||||
<AxisChart :config="dealsByStage.data.bar" />
|
||||
</div>
|
||||
<div v-if="dealsByStage.data" class="border rounded-md">
|
||||
<DonutChart :config="dealsByStage.data.donut" />
|
||||
</div>
|
||||
<div v-if="leadsBySource.data" class="border rounded-md">
|
||||
<DonutChart :config="leadsBySource.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="lostDealReasons.data" class="border rounded-md">
|
||||
<AxisChart :config="lostDealReasons.data" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-y-scroll">
|
||||
<DashboardGrid
|
||||
v-if="!dashboardItems.loading && dashboardItems.data"
|
||||
v-model="dashboardItems.data"
|
||||
:editing="editing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideRefreshCcw from '~icons/lucide/refresh-ccw'
|
||||
import LucidePenLine from '~icons/lucide/pen-line'
|
||||
import DashboardGrid from '@/components/Dashboard/DashboardGrid.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
@ -142,9 +127,6 @@ 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,
|
||||
@ -155,6 +137,8 @@ import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const { users, getUser, isManager, isAdmin } = usersStore()
|
||||
|
||||
const editing = ref(false)
|
||||
|
||||
const showDatePicker = ref(false)
|
||||
const datePickerRef = ref(null)
|
||||
const preset = ref('Last 30 Days')
|
||||
@ -177,19 +161,7 @@ const toDate = computed(() => {
|
||||
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()
|
||||
dashboardItems.reload()
|
||||
}
|
||||
|
||||
const options = computed(() => [
|
||||
@ -202,7 +174,7 @@ const options = computed(() => [
|
||||
onClick: () => {
|
||||
preset.value = 'Last 7 Days'
|
||||
filters.period = getLastXDays(7)
|
||||
reload()
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -210,7 +182,7 @@ const options = computed(() => [
|
||||
onClick: () => {
|
||||
preset.value = 'Last 30 Days'
|
||||
filters.period = getLastXDays(30)
|
||||
reload()
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -218,7 +190,7 @@ const options = computed(() => [
|
||||
onClick: () => {
|
||||
preset.value = 'Last 60 Days'
|
||||
filters.period = getLastXDays(60)
|
||||
reload()
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -226,7 +198,7 @@ const options = computed(() => [
|
||||
onClick: () => {
|
||||
preset.value = 'Last 90 Days'
|
||||
filters.period = getLastXDays(90)
|
||||
reload()
|
||||
dashboardItems.reload()
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -242,9 +214,9 @@ const options = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const numberCards = createResource({
|
||||
url: 'crm.api.dashboard.get_number_card_data',
|
||||
cache: ['Analytics', 'NumberCards'],
|
||||
const dashboardItems = createResource({
|
||||
url: 'crm.api.dashboard.get_dashboard_items',
|
||||
cache: ['Analytics', 'DashboardItems'],
|
||||
makeParams() {
|
||||
return {
|
||||
from_date: fromDate.value,
|
||||
@ -255,275 +227,15 @@ const numberCards = createResource({
|
||||
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 },
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
function save() {
|
||||
// Implement save logic here
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
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 {
|
||||
donut: {
|
||||
data: data,
|
||||
title: __('Deals by stage'),
|
||||
subtitle: __('Current pipeline distribution'),
|
||||
categoryColumn: 'stage',
|
||||
valueColumn: 'count',
|
||||
},
|
||||
bar: {
|
||||
data: data.filter((d) => d.status_type != 'Lost'),
|
||||
title: __('Deals by ongoing & won stage'),
|
||||
xAxis: {
|
||||
title: __('Stage'),
|
||||
key: 'stage',
|
||||
type: 'category' as const,
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Count'),
|
||||
},
|
||||
series: [{ name: 'count', type: 'bar' as const }],
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
}
|
||||
},
|
||||
})
|
||||
function cancel() {
|
||||
editing.value = false
|
||||
dashboardItems.reload()
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return { title: __('CRM Dashboard') }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user