1
0
forked from test/crm

fix: use GridLayout from frappe-ui to display dashboard

(cherry picked from commit 160649bf9750ef9ca6e5bee8e759a51ad4fd243a)
This commit is contained in:
Shariq Ansari 2025-07-14 12:18:14 +05:30 committed by Mergify
parent 92879d3cbe
commit c879fa57cf
5 changed files with 488 additions and 388 deletions

View File

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

View File

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

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

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

View File

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