crm/frontend/src/pages/Dashboard.vue
Shariq Ansari 72dc3904c0 fix: added deals by ongoing & won stages bar
(cherry picked from commit de85ccfc51fdf7ac5c87084baccd602930102504)
2025-07-13 09:46:21 +00:00

532 lines
14 KiB
Vue

<template>
<div class="flex flex-col h-full overflow-hidden">
<LayoutHeader>
<template #left-header>
<ViewBreadcrumbs routeName="Dashboard" />
</template>
</LayoutHeader>
<div class="p-5 pb-3 flex items-center gap-4">
<Dropdown
v-if="!showDatePicker"
:options="options"
class="form-control"
v-model="preset"
:placeholder="__('Select Range')"
:button="{
label: __(preset),
class:
'!w-full justify-start [&>span]:mr-auto [&>svg]:text-ink-gray-5 ',
variant: 'outline',
iconRight: 'chevron-down',
iconLeft: 'calendar',
}"
>
<template #prefix>
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
</template>
</Dropdown>
<DateRangePicker
v-else
class="!w-48"
ref="datePickerRef"
:value="filters.period"
variant="outline"
:placeholder="__('Period')"
@change="
(v) =>
updateFilter('period', v, () => {
showDatePicker = false
if (!v) {
filters.period = getLastXDays()
preset = 'Last 30 Days'
} else {
preset = formatter(v)
}
})
"
:formatter="formatRange"
>
<template #prefix>
<LucideCalendar class="size-4 text-ink-gray-5 mr-2" />
</template>
</DateRangePicker>
<Link
v-if="isAdmin() || isManager()"
class="form-control w-48"
variant="outline"
:value="filters.user && getUser(filters.user).full_name"
doctype="User"
:filters="{ name: ['in', users.data.crmUsers?.map((u) => u.name)] }"
@change="(v) => updateFilter('user', v)"
:placeholder="__('Sales user')"
:hideMe="true"
>
<template #prefix>
<UserAvatar
v-if="filters.user"
class="mr-2"
:user="filters.user"
size="sm"
/>
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
</div>
<div class="p-5 pt-2 w-full overflow-y-scroll">
<div class="transition-all animate-fade-in duration-300">
<div
v-if="!numberCards.loading"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
>
<Tooltip
v-for="(config, index) in numberCards.data"
:text="config.tooltip"
>
<NumberChart
:key="index"
class="border rounded-md"
:config="config"
/>
</Tooltip>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div v-if="salesTrend.data" class="border rounded-md min-h-80">
<AxisChart :config="salesTrend.data" />
</div>
<div v-if="forecastedRevenue.data" class="border rounded-md min-h-80">
<AxisChart :config="forecastedRevenue.data" />
</div>
<div v-if="funnelConversion.data" class="border rounded-md min-h-80">
<AxisChart :config="funnelConversion.data" />
</div>
<div v-if="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>
</div>
</template>
<script setup lang="ts">
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 { getLastXDays, formatter, formatRange } from '@/utils/dashboard'
import {
AxisChart,
DonutChart,
NumberChart,
usePageMeta,
createResource,
DateRangePicker,
Dropdown,
Tooltip,
} from 'frappe-ui'
import { ref, reactive, computed } from 'vue'
const { users, getUser, isManager, isAdmin } = usersStore()
const showDatePicker = ref(false)
const datePickerRef = ref(null)
const preset = ref('Last 30 Days')
const filters = reactive({
period: getLastXDays(),
user: null,
})
const fromDate = computed(() => {
if (!filters.period) return null
return filters.period.split(',')[0]
})
const toDate = computed(() => {
if (!filters.period) return null
return filters.period.split(',')[1]
})
function updateFilter(key: string, value: any, callback?: () => void) {
filters[key] = value
callback?.()
reload()
}
function reload() {
numberCards.reload()
salesTrend.reload()
funnelConversion.reload()
dealsBySalesperson.reload()
dealsByTerritory.reload()
lostDealReasons.reload()
forecastedRevenue.reload()
dealsByStage.reload()
leadsBySource.reload()
}
const options = computed(() => [
{
group: 'Presets',
hideLabel: true,
items: [
{
label: 'Last 7 Days',
onClick: () => {
preset.value = 'Last 7 Days'
filters.period = getLastXDays(7)
reload()
},
},
{
label: 'Last 30 Days',
onClick: () => {
preset.value = 'Last 30 Days'
filters.period = getLastXDays(30)
reload()
},
},
{
label: 'Last 60 Days',
onClick: () => {
preset.value = 'Last 60 Days'
filters.period = getLastXDays(60)
reload()
},
},
{
label: 'Last 90 Days',
onClick: () => {
preset.value = 'Last 90 Days'
filters.period = getLastXDays(90)
reload()
},
},
],
},
{
label: 'Custom Range',
onClick: () => {
showDatePicker.value = true
setTimeout(() => datePickerRef.value?.open(), 0)
preset.value = 'Custom Range'
filters.period = null // Reset period to allow custom date selection
},
},
])
const numberCards = createResource({
url: 'crm.api.dashboard.get_number_card_data',
cache: ['Analytics', 'NumberCards'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
})
const salesTrend = createResource({
url: 'crm.api.dashboard.get_sales_trend_data',
cache: ['Analytics', 'SalesTrend'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Sales trend'),
subtitle: __('Daily performance of leads, deals, and wins'),
xAxis: {
title: __('Date'),
key: 'date',
type: 'time' as const,
timeGrain: 'day' as const,
},
yAxis: {
title: __('Count'),
},
series: [
{ name: 'leads', type: 'line' as const, showDataPoints: true },
{ name: 'deals', type: 'line' as const, showDataPoints: true },
{ name: 'won_deals', type: 'line' as const, showDataPoints: true },
],
}
},
})
const funnelConversion = createResource({
url: 'crm.api.dashboard.get_funnel_conversion_data',
cache: ['Analytics', 'FunnelConversion'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Funnel conversion'),
subtitle: __('Lead to deal conversion pipeline'),
xAxis: {
title: __('Stage'),
key: 'stage',
type: 'category' as const,
},
yAxis: {
title: __('Count'),
},
swapXY: true,
series: [
{
name: 'count',
type: 'bar' as const,
echartOptions: {
colorBy: 'data',
},
},
],
}
},
})
const dealsBySalesperson = createResource({
url: 'crm.api.dashboard.get_deals_by_salesperson',
cache: ['Analytics', 'DealsBySalesperson'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Deals by salesperson'),
subtitle: __('Number of deals and total value per salesperson'),
xAxis: {
title: __('Salesperson'),
key: 'salesperson',
type: 'category' as const,
},
yAxis: {
title: __('Number of deals'),
},
y2Axis: {
title: __('Deal value') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'deals', type: 'bar' as const },
{
name: 'value',
type: 'line' as const,
showDataPoints: true,
axis: 'y2' as const,
},
],
}
},
})
const dealsByTerritory = createResource({
url: 'crm.api.dashboard.get_deals_by_territory',
cache: ['Analytics', 'DealsByTerritory'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Deals by territory'),
subtitle: __('Geographic distribution of deals and revenue'),
xAxis: {
title: __('Territory'),
key: 'territory',
type: 'category' as const,
},
yAxis: {
title: __('Number of deals'),
},
y2Axis: {
title: __('Deal value') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'deals', type: 'bar' as const },
{
name: 'value',
type: 'line' as const,
showDataPoints: true,
axis: 'y2' as const,
},
],
}
},
})
const lostDealReasons = createResource({
url: 'crm.api.dashboard.get_lost_deal_reasons',
cache: ['Analytics', 'LostDealReasons'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
data: data,
title: __('Lost deal reasons'),
subtitle: __('Common reasons for losing deals'),
xAxis: {
title: __('Reason'),
key: 'reason',
type: 'category' as const,
},
yAxis: {
title: __('Count'),
},
swapXY: true,
series: [{ name: 'count', type: 'bar' as const }],
}
},
})
const forecastedRevenue = createResource({
url: 'crm.api.dashboard.get_forecasted_revenue',
cache: ['Analytics', 'ForecastedRevenue'],
makeParams() {
return { user: filters.user }
},
auto: true,
transform(r = { data: [], currency_symbol: '$' }) {
return {
data: r.data || [],
title: __('Revenue forecast'),
subtitle: __('Projected vs actual revenue based on deal probability'),
xAxis: {
title: __('Month'),
key: 'month',
type: 'time' as const,
timeGrain: 'month' as const,
},
yAxis: {
title: __('Revenue') + ` (${r.currency_symbol})`,
},
series: [
{ name: 'forecasted', type: 'line' as const, showDataPoints: true },
{ name: 'actual', type: 'line' as const, showDataPoints: true },
],
}
},
})
const dealsByStage = createResource({
url: 'crm.api.dashboard.get_deals_by_stage',
cache: ['Analytics', 'DealsByStage'],
makeParams() {
return {
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
}
},
auto: true,
transform(data = []) {
return {
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',
}
},
})
usePageMeta(() => {
return { title: __('CRM Dashboard') }
})
</script>