Merge pull request #1033 from shariquerik/editable-dashboard

This commit is contained in:
Shariq Ansari 2025-07-15 13:28:50 +05:30 committed by GitHub
commit 6fefa16ac3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1198 additions and 594 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Dashboard", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,105 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:title",
"creation": "2025-07-14 12:19:49.725022",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"private",
"column_break_exbw",
"user",
"section_break_hfza",
"layout"
],
"fields": [
{
"fieldname": "column_break_exbw",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_hfza",
"fieldtype": "Section Break"
},
{
"default": "[]",
"fieldname": "layout",
"fieldtype": "Code",
"label": "Layout",
"options": "JSON"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Name",
"unique": 1
},
{
"depends_on": "private",
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"mandatory_depends_on": "private",
"options": "User"
},
{
"default": "0",
"fieldname": "private",
"fieldtype": "Check",
"label": "Private"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-14 12:36:10.831351",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Dashboard",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@ -0,0 +1,33 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class CRMDashboard(Document):
pass
def default_manager_dashboard_layout():
"""
Returns the default layout for the CRM Manager Dashboard.
"""
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
def create_default_manager_dashboard(force=False):
"""
Creates the default CRM Manager Dashboard if it does not exist.
"""
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
doc = frappe.new_doc("CRM Dashboard")
doc.title = "Manager Dashboard"
doc.layout = default_manager_dashboard_layout()
doc.insert(ignore_permissions=True)
elif force:
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
doc.layout = default_manager_dashboard_layout()
doc.save(ignore_permissions=True)
return doc.layout

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMDashboard(UnitTestCase):
"""
Unit tests for CRMDashboard.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMDashboard(IntegrationTestCase):
"""
Integration tests for CRMDashboard.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -4,6 +4,7 @@ import click
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
@ -23,6 +24,7 @@ def after_install(force=False):
add_default_lost_reasons()
add_standard_dropdown_items()
add_default_scripts()
create_default_manager_dashboard(force)
frappe.db.commit()

View File

@ -27,7 +27,7 @@ def execute():
]
for status in deal_statuses:
if status.type is None or status.type == "":
if not status.type or status.type is None or status.type == "Open":
if status.deal_status in openStatuses:
type = "Open"
elif status.deal_status in ongoingStatuses:

@ -1 +1 @@
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
Subproject commit b295b54aaa3a2a22f455df819d87433dc6b9ff6a

View File

@ -12,6 +12,7 @@ declare module 'vue' {
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
@ -62,7 +63,9 @@ declare module 'vue' {
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
@ -99,11 +102,9 @@ declare module 'vue' {
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
@ -167,11 +168,9 @@ declare module 'vue' {
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default']
LucideX: typeof import('~icons/lucide/x')['default']
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
LucidePenLine: typeof import('~icons/lucide/pen-line')['default']
LucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -205,7 +204,6 @@ declare module 'vue' {
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.166",
"frappe-ui": "^0.1.171",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -0,0 +1,165 @@
<template>
<Dialog
v-model="show"
:options="{ title: __('Add chart') }"
@close="show = false"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
v-model="chartType"
type="select"
:label="__('Chart Type')"
:options="chartTypes"
/>
<FormControl
v-if="chartType === 'number_chart'"
v-model="numberChart"
type="select"
:label="__('Number chart')"
:options="numberCharts"
/>
<FormControl
v-if="chartType === 'axis_chart'"
v-model="axisChart"
type="select"
:label="__('Axis chart')"
:options="axisCharts"
/>
<FormControl
v-if="chartType === 'donut_chart'"
v-model="donutChart"
type="select"
:label="__('Donut chart')"
:options="donutCharts"
/>
</div>
</template>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button variant="outline" :label="__('Cancel')" @click="show = false" />
<Button variant="solid" :label="__('Add')" @click="addChart" />
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { getRandom } from '@/utils'
import { createResource, Dialog, FormControl } from 'frappe-ui'
import { ref, reactive, inject } from 'vue'
const show = defineModel({
type: Boolean,
default: false,
})
const items = defineModel('items', {
type: Array,
default: () => [],
})
const fromDate = inject('fromDate', ref(''))
const toDate = inject('toDate', ref(''))
const filters = inject('filters', reactive({ period: '', user: '' }))
const chartType = ref('spacer')
const chartTypes = [
{ label: __('Spacer'), value: 'spacer' },
{ label: __('Number chart'), value: 'number_chart' },
{ label: __('Axis chart'), value: 'axis_chart' },
{ label: __('Donut chart'), value: 'donut_chart' },
]
const numberChart = ref('')
const numberCharts = [
{ label: __('Total leads'), value: 'total_leads' },
{ label: __('Ongoing deals'), value: 'ongoing_deals' },
{ label: __('Avg ongoing deal value'), value: 'average_ongoing_deal_value' },
{ label: __('Won deals'), value: 'won_deals' },
{ label: __('Avg won deal value'), value: 'average_won_deal_value' },
{ label: __('Avg deal value'), value: 'average_deal_value' },
{
label: __('Avg time to close a lead'),
value: 'average_time_to_close_a_lead',
},
{
label: __('Avg time to close a deal'),
value: 'average_time_to_close_a_deal',
},
]
const axisChart = ref('sales_trend')
const axisCharts = [
{ label: __('Sales trend'), value: 'sales_trend' },
{ label: __('Forecasted revenue'), value: 'forecasted_revenue' },
{ label: __('Funnel conversion'), value: 'funnel_conversion' },
{ label: __('Deals by ongoing & won stage'), value: 'deals_by_stage_axis' },
{ label: __('Lost deal reasons'), value: 'lost_deal_reasons' },
{ label: __('Deals by territory'), value: 'deals_by_territory' },
{ label: __('Deals by salesperson'), value: 'deals_by_salesperson' },
]
const donutChart = ref('deals_by_stage_donut')
const donutCharts = [
{ label: __('Deals by stage'), value: 'deals_by_stage_donut' },
{ label: __('Leads by source'), value: 'leads_by_source' },
{ label: __('Deals by source'), value: 'deals_by_source' },
]
async function addChart() {
show.value = false
if (chartType.value == 'spacer') {
items.value.push({
name: 'spacer',
type: 'spacer',
layout: { x: 0, y: 0, w: 4, h: 2, i: 'spacer_' + getRandom(4) },
})
} else {
await getChart(chartType.value)
}
}
async function getChart(type: string) {
let name =
type == 'number_chart'
? numberChart.value
: type == 'axis_chart'
? axisChart.value
: donutChart.value
await createResource({
url: 'crm.api.dashboard.get_chart',
params: {
name,
type,
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
},
auto: true,
onSuccess: (data = {}) => {
let width = 4
let height = 2
if (['axis_chart', 'donut_chart'].includes(type)) {
width = 10
height = 7
}
items.value.push({
name,
type,
layout: {
x: 0,
y: 0,
w: width,
h: height,
i: name + '_' + getRandom(4),
},
data: data,
})
},
})
}
</script>

View File

@ -0,0 +1,62 @@
<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"
:rowHeight="42"
: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
v-if="editing"
class="flex absolute right-0 top-0 bg-surface-gray-6 rounded cursor-pointer opacity-0 group-hover:opacity-100"
>
<div
class="rounded p-1 hover:bg-surface-gray-5"
@click="items.splice(index, 1)"
>
<FeatherIcon name="trash-2" class="size-3 text-ink-white" />
</div>
</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_chart'"
class="flex h-full w-full rounded shadow overflow-hidden cursor-pointer"
>
<Tooltip :text="__(item.data.tooltip)">
<NumberChart v-if="item.data" :key="index" :config="item.data" />
</Tooltip>
</div>
<div
v-else-if="item.type == 'spacer'"
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 ? __('Spacer') : '' }}
</div>
<div
v-else-if="item.type == 'axis_chart'"
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_chart'"
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,44 @@
<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 && (isManager() || isAdmin())"
:label="__('Edit')"
@click="enableEditing"
>
<template #prefix>
<LucidePenLine class="size-4" />
</template>
</Button>
<Button
v-if="editing"
:label="__('Chart')"
icon-left="plus"
@click="showAddChartModal = true"
/>
<Button v-if="editing" :label="__('Cancel')" @click="cancel" />
<Button
v-if="editing"
variant="solid"
:label="__('Save')"
:disabled="!dirty"
:loading="saveDashboard.loading"
@click="save"
/>
</template>
</LayoutHeader>
<div class="p-5 pb-3 flex items-center gap-4">
<div class="p-5 pb-2 flex items-center gap-4">
<Dropdown
v-if="!showDatePicker"
:options="options"
@ -83,81 +118,51 @@
</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
class="pt-1"
v-if="!dashboardItems.loading && dashboardItems.data"
v-model="dashboardItems.data"
:editing="editing"
/>
</div>
</div>
<AddChartModal
v-if="showAddChartModal"
v-model="showAddChartModal"
v-model:items="dashboardItems.data"
/>
</template>
<script setup lang="ts">
import AddChartModal from '@/components/Dashboard/AddChartModal.vue'
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'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { copy } from '@/utils'
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'
import { ref, reactive, computed, provide } 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')
const showAddChartModal = ref(false)
const filters = reactive({
period: getLastXDays(),
@ -177,19 +182,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 +195,7 @@ const options = computed(() => [
onClick: () => {
preset.value = 'Last 7 Days'
filters.period = getLastXDays(7)
reload()
dashboardItems.reload()
},
},
{
@ -210,7 +203,7 @@ const options = computed(() => [
onClick: () => {
preset.value = 'Last 30 Days'
filters.period = getLastXDays(30)
reload()
dashboardItems.reload()
},
},
{
@ -218,7 +211,7 @@ const options = computed(() => [
onClick: () => {
preset.value = 'Last 60 Days'
filters.period = getLastXDays(60)
reload()
dashboardItems.reload()
},
},
{
@ -226,7 +219,7 @@ const options = computed(() => [
onClick: () => {
preset.value = 'Last 90 Days'
filters.period = getLastXDays(90)
reload()
dashboardItems.reload()
},
},
],
@ -242,9 +235,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',
cache: ['Analytics', 'ManagerDashboard'],
makeParams() {
return {
from_date: fromDate.value,
@ -255,275 +248,50 @@ 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 },
],
}
const dirty = computed(() => {
if (!editing.value) return false
return JSON.stringify(dashboardItems.data) !== JSON.stringify(oldItems.value)
})
const oldItems = ref([])
provide('fromDate', fromDate)
provide('toDate', toDate)
provide('filters', filters)
function enableEditing() {
editing.value = true
oldItems.value = copy(dashboardItems.data)
}
function cancel() {
editing.value = false
dashboardItems.data = copy(oldItems.value)
}
const saveDashboard = createResource({
url: 'frappe.client.set_value',
method: 'POST',
onSuccess: () => {
dashboardItems.reload()
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',
},
},
],
}
},
})
function save() {
const dashboardItemsCopy = copy(dashboardItems.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,
},
],
}
},
})
dashboardItemsCopy.forEach((item: any) => {
delete item.data
})
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',
}
},
})
saveDashboard.submit({
doctype: 'CRM Dashboard',
name: 'Manager Dashboard',
fieldname: 'layout',
value: JSON.stringify(dashboardItemsCopy),
})
}
usePageMeta(() => {
return { title: __('CRM Dashboard') }

View File

@ -531,3 +531,8 @@ export function TemplateOption({ active, option, theme, icon, onClick }) {
],
)
}
export function copy(obj) {
if (!obj) return obj
return JSON.parse(JSON.stringify(obj))
}

View File

@ -952,6 +952,13 @@
dependencies:
"@floating-ui/utils" "^0.2.8"
"@floating-ui/core@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd"
integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.7":
version "1.6.12"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
@ -968,6 +975,19 @@
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.9"
"@floating-ui/dom@^1.7.0":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45"
integrity sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==
dependencies:
"@floating-ui/core" "^1.7.2"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@floating-ui/utils@^0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
@ -1022,6 +1042,11 @@
local-pkg "^1.0.0"
mlly "^1.7.4"
"@interactjs/types@1.10.27":
version "1.10.27"
resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.27.tgz#10afd71cef2498e2b5192cf0d46f937d8ceb767f"
integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==
"@internationalized/date@^3.5.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.7.0.tgz#23a4956308ee108e308517a7137c69ab8f5f2ad9"
@ -1095,6 +1120,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@juggle/resize-observer@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -1582,6 +1612,20 @@
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@vexip-ui/hooks@^2.8.0":
version "2.9.2"
resolved "https://registry.yarnpkg.com/@vexip-ui/hooks/-/hooks-2.9.2.tgz#3c6ba9670f1a4ac4211b05279e18657a3c1921ba"
integrity sha512-zdwcTZUHYD/5aqndmUulyia4tPMI3FB09PUn674hZiQlkslO1KiH56WAI8R75wbvzPSmmhl5IA3VcbBZeaFEcw==
dependencies:
"@floating-ui/dom" "^1.7.0"
"@juggle/resize-observer" "^3.4.0"
"@vexip-ui/utils" "2.16.4"
"@vexip-ui/utils@2.16.4", "@vexip-ui/utils@^2.16.1":
version "2.16.4"
resolved "https://registry.yarnpkg.com/@vexip-ui/utils/-/utils-2.16.4.tgz#3429376a8f9e88040e969c21f14e70fe25d36127"
integrity sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==
"@vitejs/plugin-vue-jsx@^3.0.1":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz#9953fd9456539e1f0f253bf0fcd1289e66c67cd1"
@ -2572,10 +2616,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.166:
version "0.1.166"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3"
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw==
frappe-ui@^0.1.171:
version "0.1.171"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.171.tgz#10c582ea62292461ff37bb0b3ac2269409a373e9"
integrity sha512-hIwban7j7qa+n/F6bZ+B78jYyGGj1gnibR/k0Kdx1SYPCfMdYr2TfZA8ySpbIvqWpeYxCus6nS4MD+wf0DpUOw==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
@ -2608,6 +2652,7 @@ frappe-ui@^0.1.166:
dompurify "^3.2.6"
echarts "^5.6.0"
feather-icons "^4.28.0"
grid-layout-plus "^1.1.0"
highlight.js "^11.11.1"
idb-keyval "^6.2.0"
lowlight "^3.3.0"
@ -2773,6 +2818,15 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
grid-layout-plus@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/grid-layout-plus/-/grid-layout-plus-1.1.0.tgz#4c6610ff3aa39ddea2953861c224d1914bf5a33d"
integrity sha512-Q5uj0U5nx6xfHg8G1CDRJAEg+/40RVJl5jjRImcRwC78BxoJrEkTneT1pyxYMlbZ8fpGPT6QdHJQkD4+W6gt5A==
dependencies:
"@vexip-ui/hooks" "^2.8.0"
"@vexip-ui/utils" "^2.16.1"
interactjs "^1.10.27"
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
@ -2854,6 +2908,13 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
interactjs@^1.10.27:
version "1.10.27"
resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.27.tgz#16499aba4987a5ccfdaddca7d1ba7bb1118e14d0"
integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==
dependencies:
"@interactjs/types" "1.10.27"
internal-slot@^1.0.7, internal-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"