Разработка дашбордов на Vue.js для Битрикс24
Встроенная аналитика Битрикс24 покрывает стандартные сценарии: воронка продаж, отчёт по менеджерам, план/факт. Как только бизнес-аналитик приходит с запросом «покажи конверсию по источникам в разрезе регионов за произвольный период с фильтром по ответственному» — стандартные отчёты заканчиваются. Дашборд на Vue.js — это кастомный интерфейс аналитики, встроенный в Битрикс24 через iframe-приложение с прямым доступом к REST API.
Архитектурная схема дашборда
Дашборд состоит из трёх слоёв:
- Слой данных — запросы к Битрикс24 REST API, кэширование, агрегация
- Слой хранилища — Pinia stores с реактивными вычисляемыми свойствами
- Слой представления — Vue-компоненты с Chart.js или ApexCharts
Для тяжёлых агрегаций — промежуточный серверный слой (Node.js/PHP), который сводит данные из нескольких REST-вызовов и отдаёт готовый JSON.
Пакетный сбор данных
Битрикс24 REST API имеет лимит 50 элементов на запрос. Для получения всех сделок за период нужна пагинация или использование callBatch:
// composables/useCrmData.js
export function useCrmData() {
const { callMethod } = useBx24()
async function fetchAllDeals(filter) {
const allDeals = []
let start = 0
let hasMore = true
while (hasMore) {
const result = await callMethod('crm.deal.list', {
select: ['ID', 'TITLE', 'STAGE_ID', 'OPPORTUNITY', 'SOURCE_ID',
'ASSIGNED_BY_ID', 'DATE_CREATE', 'UF_CRM_REGION'],
filter,
order: { ID: 'ASC' },
start,
})
allDeals.push(...result)
hasMore = result.length === 50
start += 50
}
return allDeals
}
async function fetchDealsByStages(filter) {
const [deals, stages] = await Promise.all([
fetchAllDeals(filter),
callMethod('crm.status.list', {
filter: { ENTITY_ID: 'DEAL_STAGE' }
})
])
return { deals, stages }
}
return { fetchAllDeals, fetchDealsByStages }
}
Компонент фильтра периода
<!-- components/DateRangeFilter.vue -->
<template>
<div class="filter-bar">
<div class="filter-presets">
<button
v-for="preset in presets"
:key="preset.id"
:class="{ active: activePreset === preset.id }"
@click="applyPreset(preset)"
>{{ preset.label }}</button>
</div>
<div class="filter-custom">
<input type="date" v-model="dateFrom" @change="emitCustomRange" />
<input type="date" v-model="dateTo" @change="emitCustomRange" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { startOfMonth, endOfMonth, subMonths, format } from 'date-fns'
const emit = defineEmits(['change'])
const activePreset = ref('current_month')
const dateFrom = ref('')
const dateTo = ref('')
const presets = [
{ id: 'current_month', label: 'Текущий месяц' },
{ id: 'prev_month', label: 'Прошлый месяц' },
{ id: 'quarter', label: 'Квартал' },
{ id: 'year', label: 'Год' },
]
function applyPreset(preset) {
activePreset.value = preset.id
const now = new Date()
let from, to
switch (preset.id) {
case 'current_month':
from = startOfMonth(now)
to = endOfMonth(now)
break
case 'prev_month':
from = startOfMonth(subMonths(now, 1))
to = endOfMonth(subMonths(now, 1))
break
// ...
}
emit('change', {
from: format(from, 'yyyy-MM-dd'),
to: format(to, 'yyyy-MM-dd'),
})
}
</script>
Интеграция Chart.js
// composables/useChart.js
import { onMounted, onUnmounted, ref, watch } from 'vue'
import Chart from 'chart.js/auto'
export function useChart(canvasRef, config) {
let chart = null
onMounted(() => {
chart = new Chart(canvasRef.value, config.value)
})
watch(config, (newConfig) => {
if (!chart) return
chart.data = newConfig.data
chart.update('active')
}, { deep: true })
onUnmounted(() => chart?.destroy())
}
Компонент воронки продаж:
<template>
<div class="chart-card">
<h3>Воронка продаж</h3>
<canvas ref="canvasRef" height="300"></canvas>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useChart } from '@/composables/useChart'
const store = useDashboardStore()
const canvasRef = ref(null)
const chartConfig = computed(() => ({
type: 'bar',
data: {
labels: store.stages.map(s => s.NAME),
datasets: [{
label: 'Количество сделок',
data: store.stages.map(s => store.dealsByStage[s.STATUS_ID] || 0),
backgroundColor: store.stages.map(s => s.COLOR || '#4a90d9'),
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false } }
}
}))
useChart(canvasRef, chartConfig)
</script>
KPI-карточки
Агрегация метрик на клиенте:
// stores/dashboard.js
const kpis = computed(() => {
const deals = filteredDeals.value
const won = deals.filter(d => d.STAGE_ID === 'WON')
const total = deals.length
return {
totalDeals: total,
wonDeals: won.length,
conversionRate: total ? Math.round((won.length / total) * 100) : 0,
totalRevenue: won.reduce((sum, d) => sum + parseFloat(d.OPPORTUNITY || 0), 0),
avgDealSize: won.length
? won.reduce((sum, d) => sum + parseFloat(d.OPPORTUNITY || 0), 0) / won.length
: 0,
}
})
Экспорт данных
Выгрузка дашборда в Excel через xlsx:
import * as XLSX from 'xlsx'
function exportToExcel(data, filename) {
const ws = XLSX.utils.json_to_sheet(data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Отчёт')
XLSX.writeFile(wb, `${filename}.xlsx`)
}
Производительность
Дашборды с большим объёмом данных требуют:
- Серверная агрегация — не гоняйте 10 000 сделок в браузер
-
shallowRefдля больших массивов в Pinia — не нужна глубокая реактивность на каждый объект - Виртуализация таблиц —
@tanstack/vue-virtualдля списков > 500 строк - Дебаунс на фильтрах — 300–500 мс перед запросом
Сроки выполнения
Дашборд с 3–5 виджетами (воронка, KPI-карточки, динамика по периодам) — 5–8 рабочих дней. Аналитический портал с десятком виджетов, серверной агрегацией, экспортом и ролями доступа — 3–5 недель.







