
Привет! Меня зовут Игорь Росляков, я технический писатель. По приглашению руководителя направления «Маркет и интеграции» Сергея Вострикова я готовлю цикл статей на тему ИИ-ассистированной разработки решений для Битрикс24. Сегодня начинаю рассказывать о полезных штуках, которые можно добавить в свой портал в веб-приложении, чтобы вести бизнес было удобнее и проще.
Для проекта я буду использовать AI-стартер — нашу подготовленную ИИ-базу. Это шаблон проекта с инструкциями-промптами для искусственного интеллекта. В статье расскажу, насколько это было сложно, где агенты ошибались самостоятельно, а где — из-за меня.
Короче, если хотите начать оптимизировать работу в CRM через кастомные приложения и посмотреть, с чего начинать и что бывает в результате — вам сюда.
Что будет в статье:
Краткое описание проекта
Сегодня мы сделаем пробное простое приложение, которое добавляет полезные метрики в карточки клиентов и компаний. На платформе можно отслеживать финансовые сделки, которые можно разделить на 3 вида:
Удачные — то есть уже завершившиеся и принесшие прибыль.
Проваленные — завершившиеся с потерей потенциальной прибыли или убыточные.
Потенциально удачные — сделки в процессе, которые могут принести прибыль в будущем.
По умолчанию это всё можно посмотреть на отдельной вкладке. Для этого переходим на панель CRM и нажимаем кнопку «Сделки»:

Мы сделаем удобнее: у клиентов и компаний будут встроенные вкладки-дашборды, где можно посмотреть сводную информацию о сделках в разных валютах:

Для создания мы воспользуемся нашим стартер-китом AI-ассистированной разработки для ускорения разработки и внедрения новых продуктов нашей системы.
Ссылка на репозиторий с AI-стартером тут:
github.com/bitrix-tools/b24-ai-starterСсылка на статью о том, зачем он нужен и как работает:
habr.com/ru/companies/bitrix/articles/987920/
Мы сегодня пишем простое приложение для первой вводной статьи, поэтому все API-запросы для реализации задачи будут идти с фронтенда с использованием JS SDK — официальной JavaScript-библиотекой Bitrix24 для работы с платформой из фронтенда.
Ссылка на репозиторий с финальным проектом:
github.com/igorrosliakov-bitrix24/Bitrix24-Create-Deals-Dashboards
ИИ-агенты хорошо пишут код, но при реализации могут заблудиться и добавить то, чего в проекте быть не должно — или должно, но в другом месте. Поэтому мы будем проверять всю работу и показывать, где машина ошиблась конкретно в нашем случае.
За время работы я протестировал 3 агента:
GitHub Copilot.
Сursor.
Codex от ChatGPT.
В итоге Codex оказался единственным агентом, который распознал ошибку на старте работы, заработал кредит доверия и остался единственным агентом. А ещё у остальных закончились бесплатные промпты, а на ChatGPT была подписка.
Поехали.
Копируем и настраиваем проект
Настраиваем проект по шагам.
Что нужно установить перед работой:
Docker и Docker Compose для контейнеризации.
Git для контроля версий и пуша в удалённый репозиторий.
makeдля коротких команд.Публичный HTTPS-туннель через CloudPub. Получить можно через десктопный клиент или инструмент командной строки
clo.Возможно, понадобится, но не наверняка: Node.js/pnpm и Xcode CLT для macOS toolchain.
Утилита make запускает готовые команды по коротким именам из файла Makefile. Чтобы поднять контейнеры или запустить мастер установки, понадобится одна короткая команда, которая под капотом может включать в себя длинный скрипт. Что можно сказать про make в разных системах:
На Linux часто уже есть или ставится пакетом.
На macOS приходит с Xcode Command Line Tools.
На Windows в чистом виде обычно ��ет, поэтому ставят через WSL2 или отдельно через пакеты.
На Windows вообще лучше работать через Linux-подсистему WSL2 (Ubuntu). Это потому, что проект и Docker-сценарии чаще ориентированы на Linux-окружение и могут работать в Windows не так стабильно.
Теперь настройка.
Шаг 1. Клонируем наш репозиторий AI-стартера: github.com/bitrix-tools/b24-ai-starter. Получаем шаблон проекта.
В репозитории уже есть полностью готовое приложение, которое можно использовать как старт для создания новых функций в своём портале. Здесь уже подключены библиотека JS SDK, UI Kit для использования графических элементов в стиле платформы и написаны подробные инструкции для ИИ-агентов.
Шаг 2. Запускаем мастера настройки командой make dev-init.

Шаг 3. Для запуска приложения понадобится ключ API в сервисе CloudPub, который даёт локальному приложению публичный HTTPS-адрес. Он нужен как туннель между Bitrix24 и локальной разработкой. Получить ключ можно на главной странице личного кабинета после регистрации.
После этого у вас появится технический домен CloudPub, который можно посмотреть в файле .env по значению VIRTUAL_HOST:

Шаг 4. В процессе установки можно выбрать тип бэкенда, подключение брокера сообщений и учётные данные администратора. Мы в проекте реализуем всё через фронтен��, но для установки выбрали Python.
В ближайшем будущем сделаем вариант стартера только на фронтенде для простых приложений.
Регистрируем приложение на портале Битрикс24
Для подключения сервиса заходим на свой портал на bitrix24.ru в раздел Разработчикам:

После этого выбираем: Другое — Локальное приложение. Появится окно регистрации:

Сейчас нужно скопировать ваш технический домен и вставить его в таком виде:
Путь вашего обработчика*:
[технический-домен]Путь для первоначальной установки:
[технический-домен]/install
Чтобы приложение могло добавить встройки, ему нужно выдать права. Для нашей задачи на сегодня понадобятся 2 пункта: CRM и Встраивание приложений.
После сохранения появится окно, откуда нужно скопировать значения для ключей CLIENT_ID и CLIENT_SECRET для .env в корневой директории проекта:

Сохраняем приложение и нажимаем «Переустановить».
На этом месте вайб-разработка на какое-то время серьёзно застопорилась, потому что я забыл добавить права на добавление placement-встроек. Потратив все бесплатные промпты в GitHub Copilot и Cursor, вместе с Codex мы нашли ошибку через логи в консоли DevTools:

Тут прямо написано о недостаточном уровне прав.
Проблема была в том, что агенты нашли эту ошибку далеко не сразу, и вместо совета перепроверить права на портале создали кучу функций для дебага и логирования внутри проекта. Если сразу выставить нужные права, они не нужны.
💡 Если находите причину поломки задним числом — объясните причину ИИ и попросите убрать ненужные фрагменты кода, чтобы не усложнять архитектуру и не открывать потенциальную уязвимость в будущем.
Проверяем, что приложение установилось и видно на портале
После установки приложения переходим на главную страницу. Для этого нужно выбрать кнопку «Перейти к приложению».
Если всё в порядке, появляется экран установки и в конце — анимация в виде конфетти. После этого видно окно тестирования.

При первом запуске всё может быть не очень быстро, но это нормально. Сейчас приложение работает в dev-режиме, и интерфейс собирается на ходу на Nuxt.
Создаём промпт для AI-агента
Загружаем репозиторий в любую платформу для ИИ-программирования на своё усмотрение. Для начала зададим правила и попросим пошаговый план для реализации нашей задачи.
Наш промпт в примере ниже выглядит сложно, подробно и включает технические нюансы. Почему так: хотя агенты хороши в разработке кода, но по умолчанию чаще всего не знают особенностей конкретной платформы. Поэтому через промпт мы хотим им помочь и направить в нужном направлении.
Но есть другой способ: отправлять ИИ внимательно изучать базу знаний внутри репозитория. Это хороший способ с точки зрения знакомства с проектом и понимания особенностей технического задания. Подсказки внутри репозитория должны заменить необходимость сложного промпта. Если вы не хотите разбираться в подробностях устройства платформы, отправляйте агента изучать репозиторий и искать все инструкции.
Третий вариант — попросить написать промпт у другого ИИ, подробно описав ему задачу и скинув максимум информации о проекте и о том, где можно найти инструкции правильного выполнения. В нашем шаблоне инструкции подготовлены внутри репозитория.
Наш подробный и длинный промпт выглядит так:
Промпт для создания приложения
Ты -- senior разработчик Bitrix24 приложений и знаешь b24-ai-starter. Работаем ВНУТРИ этого репозитория (у меня уже запущено окружение через ёmake dev-init, есть CloudPub домен). Цель задания: Сделать демонстрационное приложение на базе AI Starter, которое: 1) устанавливается в портал Bitrix24, 2) добавляет встройку (placement) в карточку Контакта и Компании, 3) в этих встройках показывает дашборд с 3 показателями по сделкам: - полученная выручка = сумма закрытых выигранных сделок, - потерянная выручка = сумма закрытых проигранных сделок, - потенциальная выручка = сумма открытых сделок. Требования/ограничения: - НИКАКОГО серверного бекенда для теста: все запросы выполняются с фронтенда. - Использовать официальный Bitrix24 JS SDK и REST API. - Интерфейс на UI Kit (визуально близко к стандартному интерфейсу Bitrix24). - Должны быть состояния loading/error/empty. - Нужны понятные, минимальные изменения кода и воспроизводимость. Твои действия: A) СНАЧАЛА исследуй репозиторий: 1) Найди, где в стартере уже реализованы install-эндпоинт/страница установки (/install) и регистрация приложения. 2) Найди, где в стартере уже описаны placements/встройки (контакт/компания) -- возможно уже есть готовые примеры "из коробки". 3) Найди, как в проекте выполняются REST вызовы через JS SDK (какие модули/ композаблы/плагины используются). B) Затем предложи план реализации в 6-10 шагов с конкретными файлами. Для каждого шага укажи: - какие файлы меняем/создаём (пути), - что именно добавляем, - как проверить результат (какой URL/где в UI Bitrix24). C) Реализация: 1) Сделай одну общую страницу/компонент Dashboard, которую можно переиспользовать и в карточке Контакта, и в карточке Компании. 2) Подключи получение сделок через REST API Bitrix24 с фронта. - Используй безопасный лимит пагинации (обработай next/offset) или честно зафиксируй упрощение, если берёшь только первые N сделок (но лучше сделать нормально). - Определи "won/lost/open" на основе полей сделки: * закрыта/открыта (например CLOSED/стадии), * признак победы/проигрыша (например STAGE_SEMANTIC или эквивалент). - Суммируй сумму сделки (например OPPORTUNITY). 3) UI: - Три KPI карточки с подписями и значениями, - форматирование валюты (учти, что валюта может быть разная: показывай код валюты или бери currency из сделки/портала), - добавь "Обновить" (кнопка) и время последнего обновления (опционально). 4) Встройки: - обеспечь отображение этого Dashboard в карточке Контакта и Компании (placement). - если в стартере уже есть placements -- адаптируй. - если нет -- добавь минимально нужную регистрацию placements для contact/company. D) В конце дай: 1) список изменённых/новых файлов, 2) команды для локальной проверки (что запустить, какие логи смотреть), 3) чек-лист для скриншотов (развернутый проект, установка, виджет в контакте/компании, финальный интерфейс), 4) если встретятся "неясные места" в API -- не задавай мне вопросы, а выбери наиболее разумную реализацию на основе примеров стартера и официальной практики Bitrix24, и явно отметь допущение в комментарии к коду. Важно: - Не добавляй RabbitMQ и очереди. - Не выноси логику в backend. - Держи решение простым и читабельным. Начинай с анализа структуры проекта и укажи, где именно в репозитории уже есть релевантные примеры.
Запускаем и убеждаемся, что ИИ сделал тот минимум, который нужен для старта: предложил план реализации, который можно отслеживать.
Важно, чтобы после каждого шага агент выдавал понятный критерий, по которому можно понять, что приложение работает так, как надо. Но даже если агент реализует сразу весь проект — на небольших задачах так тоже можно. С более сложными приложениями лучше всё же поэтапно.
Создаём встройки в карточках
Общий пайплайн выглядит так:
AI-агент создаёт новые файлы или добавляет в уже существующие новые функции. В конце каждого шага агент должен выдать критерий, как проверить успешное создание шага: что видно в интерфейсе портала, какие логи возвращает консоль приложения, что видно в статусе терминала проекта.
Разработчик приложения проверяет успешное завершение шага по критериям.
Если всё работает согласно плану, разработчик принимает изменения, делает коммит и даёт команду ИИ переходить к следующему шагу.
Ниже разбираем, что изменилось конкретно в нашем репозитории.
index.client.vue
Это главная страница приложения и точка самовосстановления встроек в карточки.
Особенность архитектуры в том, что если приложение с интерфейсом — а тем более, если оно целиком состоит из фронтенда, то без главной страницы не обойтись. Обычно туда затаскивают какой-то основной UI приложения.
Нам для задачи эта страница тоже не очень нужна, но без неё никак.
! При создании этого скрипта надо обратить внимание на поведение агента. При первой реализации ИИ добавил на главную страницу установку встроек placement. Но это неправильно. Если сделать встройки в index.client.vue, то при любом клике на приложение в меню они каждый раз будут переустанавливаться.
Правильно — делать встройки только в install.client.vue. Мы об этом написали в инструкциях, но нейросети всё равно могут интерпретировать рекомендации слишком вольно, поэтому пока за ними надо присматривать.
Изменения
Как работает index.client.vue:
Инициализирует приложение.
Делает read-only
placement.getдля проверки и логов.Показывает основной UI страницы.
index.client.vue, полный код
<script setup lang="ts"> import type { B24Frame } from '@bitrix24/b24jssdk' import { onMounted } from 'vue' import { useDashboard } from '@bitrix24/b24ui-nuxt/utils/dashboard' const { t, locales: localesI18n, setLocale } = useI18n() useHead({ title: t('page.index.seo.title') }) // region Инициализация //// const { $logger, initApp, processErrorGlobal } = useAppInit('IndexPage') const { $initializeB24Frame } = useNuxtApp() let $b24: null | B24Frame = null const apiStore = useApiStore() // endregion //// /** * [NEW BLOCK] * Преобразует объект ошибки SDK в читаемую однострочную запись для логов. * Полезно для случаев, когда AjaxError имеет статус 200 и скрытый payload. */ function describeAjaxError(error: any): string { try { const responseData = error?.response?.data || error?.data || error?.answer const payload = typeof responseData === 'string' ? responseData : JSON.stringify(responseData) const code = error?.error || error?.code || error?.status || 'unknown' const message = error?.message || error?.description || 'Unknown error' return `[code=${code}] ${message}; payload=${payload || 'n/a'}` } catch { return String(error?.message || error || 'Unknown error') } } // region Действия //// async function getEnums() { const enums = await apiStore.getEnum() $logger.info(enums) } async function getItems() { const items = await apiStore.getList() $logger.info(items) } // endregion //// const { contextId, isLoading: isLoadingState, load } = useDashboard({ isLoading: ref(false), load: () => {} }) const isLoading = computed({ get: () => isLoadingState?.value === true, set: (value: boolean) => { $logger.info(load, value, contextId, isLoadingState?.value) load?.(value, contextId) } }) // region Хуки жизненного цикла //// const isInit = ref(false) onMounted(async () => { $logger.info('Hi from index page') try { isLoading.value = true $b24 = await $initializeB24Frame() await initApp($b24, localesI18n, setLocale) // Заголовок вкладки приложения в слайдере/iframe Bitrix24. await $b24.parent.setTitle(t('page.index.seo.title')) // Главная страница не выполняет регистрацию placement. // Привязка вкладок должна выполняться в install-flow, чтобы избежать переустановки при каждом открытии приложения. try { const placementResp = await $b24.callBatch({ placementList: { method: 'placement.get' } }) const placementList = placementResp.getData()?.placementList || [] $logger.info('placement.get (read-only)', placementList) } catch (error) { $logger.warn(`placement.get unavailable on index: ${describeAjaxError(error)}`) } isInit.value = true } catch (error) { processErrorGlobal(error) } finally { isLoading.value = false } }) // endregion //// </script> <template> <div class="flex flex-col items-center justify-center gap-16 h-[calc(100vh-200px)]"> <B24Card v-if="isInit" :b24ui="{ footer: 'flex flex-row flex-wrap items-center justify-start gap-2' }" > <template #header> <ProseH2>{{ $t('page.index.message.title') }}</ProseH2> <ProseP>{{ $t('page.index.message.line1') }}</ProseP> </template> <BackendStatus /> <template #footer> <B24Button label="getEnums" loading-auto @click="getEnums" /> <B24Button label="getItems" loading-auto @click="getItems" /> </template> </B24Card> </div> </template>
install.client.vue
Это install-flow: сценарий установки приложения в портал Bitrix24. Здесь регистрируются встройки, выполняются сервисные шаги и завершение установки. Без устойчивого install-flow приложение может быть установлено формально: например, вкладки не появляются, и отладка занимает много времени.
В install-flow ключевая идея — наблюдаемость: вы не просто делаете действия, вы подтверждаете результат.
Изменения
Добавлены шаги регистрации двух dashboard
placementдля контакта и компании.Добавлен bindDashboardPlacement(...): сейчас он единообразно регистрирует обработчики вкладок и уменьшает дублирование кода в двух шагах.
makeInit() настроен так, чтобы получать
app.info,profile,placement.getи не ронять весь install при частичных проблемах.bindDashboardPlacement(...) — унифицированный bind для карточки контакта и компании. То есть мы привязываем URL обработчика к месту интерфейса, например:
CRM_CONTACT_DETAIL_TABилиCRM_COMPANY_DETAIL_TAB.
install.client.vue, полный код
<script setup lang="ts"> import type { ProgressProps } from '@bitrix24/b24ui-nuxt' import type { IStep } from '#shared/types/base' import type { B24Frame } from '@bitrix24/b24jssdk' import { ref, onMounted } from 'vue' import { sleepAction } from '~/utils/sleep' import { withoutTrailingSlash } from 'ufo' import Logo from '~/components/Logo.vue' const { t, locales: localesI18n, setLocale } = useI18n() useHead({ title: t('page.install.seo.title') }) // region Инициализация //// const config = useRuntimeConfig() const configuredAppUrl = withoutTrailingSlash(config.public.appUrl || '') const runtimeOrigin = import.meta.client && window.location?.origin ? withoutTrailingSlash(window.location.origin) : '' const appUrl = configuredAppUrl || runtimeOrigin const { $logger, initLang, processErrorGlobal } = useAppInit('Install') const { $initializeB24Frame } = useNuxtApp() const $b24: B24Frame = await $initializeB24Frame() await initLang($b24, localesI18n, setLocale) const confetti = useConfetti() const isShowDebug = ref(false) const progressColor = ref<ProgressProps['color']>('air-primary') const progressValue = ref<null | number>(null) const apiStore = useApiStore() /** * [NEW BLOCK] * Унифицированный placement.bind для вкладок дашборда в install-шаге. */ async function bindDashboardPlacement(placement: string, handlerPath: string): Promise<void> { // Централизованный bind для единообразной регистрации вкладок контакта и компании. const handler = `${appUrl}${handlerPath}` const result = await $b24.callBatch([{ method: 'placement.bind', params: { PLACEMENT: placement, HANDLER: handler, TITLE: 'Deals Dashboard' } }]) $logger.info(`placement.bind success: ${placement}`, result?.getData?.() || {}) } // endregion //// // region Шаги //// const steps = ref<Record<string, IStep>>({ init: { caption: t('page.install.step.init.caption'), action: makeInit }, demo: { caption: t('page.install.step.demo.caption'), action: async () => { return sleepAction(1000) } }, // events: { // caption: t('page.install.step.events.caption'), // action: async () => { // /** // * Регистрация onAppInstall | onAppUninstall // */ // await $b24.callBatch([ // { // method: 'event.unbind', // params: { // event: 'ONAPPINSTALL', // handler: `${appUrl}/api/event/onAppInstall` // } // }, // { // method: 'event.unbind', // params: { // event: 'ONAPPUNINSTALL', // handler: `${appUrl}/api/event/onAppUninstall` // } // }, // { // method: 'event.bind', // params: { // event: 'ONAPPINSTALL', // handler: `${appUrl}/api/event/onAppInstall` // } // }, // { // method: 'event.bind', // params: { // event: 'ONAPPUNINSTALL', // handler: `${appUrl}/api/event/onAppUninstall` // } // } // ]) // } // }, placement: { caption: t('page.install.step.placement.caption'), action: async () => { // Этот демонстрационный placement оставлен для примера шаблона AI Starter. /** * [REPLACED BLOCK] * Этот шаг обёрнут в try/catch, чтобы install-flow не падал, * если методы placement недоступны или ограничены правами. */ try { const key = { placement: 'CRM_DEAL_DETAIL_TAB', handler: `${appUrl}/handler/placement-crm-deal-detail-tab` } const placementList = (steps.value.init?.data?.placementList as any[]) || [] const exists = placementList.some(item => item.placement === key.placement && item.handler === key.handler ) if (exists) { await $b24.callBatch([ { method: 'placement.unbind', params: { PLACEMENT: key.placement } }, { method: 'placement.bind', params: { PLACEMENT: key.placement, HANDLER: key.handler, TITLE: '[demo] Some Tab', OPTIONS: { errorHandlerUrl: `${appUrl}/handler/background-some-problem` } } } ]) return } await $b24.callBatch([ { method: 'placement.bind', params: { PLACEMENT: key.placement, HANDLER: key.handler, TITLE: '[demo] Some Tab', OPTIONS: { errorHandlerUrl: `${appUrl}/handler/background-some-problem` } } } ]) } catch (error) { $logger.warn('placement.bind for CRM_DEAL_DETAIL_TAB failed') } } }, dashboardContact: { caption: 'Register Contact Dashboard', action: async () => { try { await bindDashboardPlacement('CRM_CONTACT_DETAIL_TAB', '/handler/dashboard-contact') } catch (error) { $logger.warn('CRM_CONTACT_DETAIL_TAB placement failed', error) } } }, dashboardCompany: { caption: 'Register Company Dashboard', action: async () => { try { await bindDashboardPlacement('CRM_COMPANY_DETAIL_TAB', '/handler/dashboard-company') } catch (error) { $logger.warn('CRM_COMPANY_DETAIL_TAB placement failed', error) } } }, userFields: { caption: t('page.install.step.userFields.caption'), action: async () => { const typeId = `some_type_${import.meta.dev ? 'dev' : 'prod'}` const exists = (steps.value.init?.data?.userFieldTypeList as { USER_TYPE_ID: string }[]).some(item => item.USER_TYPE_ID === typeId) if (exists) { await $b24.callBatch([ { method: 'userfieldtype.update', params: { USER_TYPE_ID: typeId, HANDLER: `${appUrl}/handler/uf.demo`, TITLE: `[${import.meta.dev ? 'dev' : 'prod'}] Some Type`, DESCRIPTION: `Some Description`, OPTIONS: { height: 105 } } } ], false) return } await $b24.callBatch([ { method: 'userfieldtype.add', params: { USER_TYPE_ID: typeId, HANDLER: `${appUrl}/handler/uf.demo`, TITLE: `[${import.meta.dev ? 'dev' : 'prod'}] Some Type`, DESCRIPTION: `Some Description`, OPTIONS: { height: 105 } } } ], false) } }, // crm: { // caption: t('page.install.step.crm.caption'), // action: async () => { // /** // * Пример действий для CRM // */ // if (steps.value.crm) { // steps.value.crm.data = { // par31: 'val31', // par32: 'val32' // } // } // return sleepAction() // } // }, serverSide: { caption: t('page.install.step.serverSide.caption'), action: async () => { const authData = $b24.auth.getAuthData() if (authData === false) { $logger.warn('postInstall skipped: auth data unavailable') return } try { await apiStore.postInstall({ DOMAIN: withoutTrailingSlash(authData.domain).replace('https://', '').replace('http://', ''), PROTOCOL: authData.domain.includes('https://') ? 1 : 0, LICENSE: steps.value.init?.data?.appInfo.LICENSE, LICENSE_FAMILY: steps.value.init?.data?.appInfo.LICENSE_FAMILY, LANG: $b24.getLang(), APP_SID: $b24.getAppSid(), AUTH_ID: authData.access_token, AUTH_EXPIRES: authData.expires_in, REFRESH_ID: authData.refresh_token, REFRESH_TOKEN: authData.refresh_token, member_id: authData.member_id, user_id: Number(steps.value.init?.data?.profile.ID), status: steps.value.init?.data?.appInfo.STATUS, appVersion: Number(steps.value.init?.data?.appInfo.VERSION), appCode: steps.value.init?.data?.appInfo.CODE, appId: Number(steps.value.init?.data?.appInfo.ID), PLACEMENT: $b24.placement.title, PLACEMENT_OPTIONS: $b24.placement.options }) } catch (error) { // Optional for static-only deployments: continue install flow and call installFinish(). $logger.warn('postInstall failed, continue install flow', error) } } }, finish: { caption: t('page.install.step.finish.caption'), action: makeFinish } }) const stepCode = ref<string>('init' as const) // endregion //// // region Действия //// async function makeInit(): Promise<void> { if (steps.value.init) { /** * [REPLACED BLOCK] * Предыдущее поведение: * - один callBatch с appInfo/profile/userfieldtype.list/placement.get. * Текущее поведение: * - сначала запрашиваем appInfo/profile. * - userfieldtype.list и placement.get запрашиваем безопасно в отдельных try/catch. * Зачем: * - избежать полного падения init, если опциональные методы недоступны. * * [REMOVED LEGACY BLOCK] * - Удалён старый монолитный блок присвоений в пользу более устойчивой композиции. */ let userFieldTypeListData: any[] = [] let placementListData: any[] = [] const response = await $b24.callBatch({ appInfo: { method: 'app.info' }, profile: { method: 'profile' } }) try { const userFieldResponse = await $b24.callBatch({ userFieldTypeList: { method: 'userfieldtype.list' } }) userFieldTypeListData = userFieldResponse.getData()?.userFieldTypeList || [] } catch (e) { $logger.warn('userfieldtype.list unavailable') } try { const placementResponse = await $b24.callBatch({ placementList: { method: 'placement.get' } }) placementListData = placementResponse.getData()?.placementList || [] } catch (e) { $logger.warn('placement.get unavailable') } const data = response.getData() steps.value.init.data = { appInfo: data.appInfo, profile: data.profile, userFieldTypeList: userFieldTypeListData, placementList: placementListData } as any } } async function makeFinish(): Promise<void> { progressColor.value = 'air-primary-success' progressValue.value = 100 confetti.fire() await sleepAction(3000) await $b24.installFinish() } const stepsData = computed(() => { return Object.entries(steps.value).map(([index, row]) => { return { step: index, data: row?.data } }) }) // endregion //// // region Хуки жизненного цикла //// onMounted(async () => { $logger.info('Hi from install page') try { await $b24.parent.setTitle(t('page.install.seo.title')) for (const [key, step] of Object.entries(steps.value)) { stepCode.value = key await step.action() } } catch (error: any) { processErrorGlobal(error) } }) // endregion //// </script> <template> <div class="mx-3 flex flex-col items-center justify-center gap-1 h-dvh"> <Logo class="size-[208px]" :class="[ stepCode === 'finish' ? 'text-(--ui-color-accent-main-success)' : 'text-(--ui-color-accent-soft-green-1)' ]" /> <B24Progress v-model="progressValue" size="xs" animation="elastic" :color="progressColor" class="w-1/2 sm:w-1/3" /> <div class="mt-6 flex flex-col items-center justify-center gap-2"> <ProseH1 class="text-nowrap mb-0"> {{ $t('page.install.ui.title') }} </ProseH1> <ProseP small accent="less"> {{ steps[stepCode]?.caption || '...' }} </ProseP> </div> <ProsePre v-if="isShowDebug"> {{ stepsData }} </ProsePre> </div> </template>
useDealStats.ts (новый файл)
Основная бизнес-логика аналитики: загрузка сделок из Bitrix24 REST с фронтенда и расчёт KPI для дашборда. Скрипт добавляет корректные данные в UI-карточки.
Основные функции
useDealStats(b24, entityId, entityType). Главная функция composable. Принимает SDK-клиент, ID сущности и тип — контакт или компания. Возвращает реактивные KPI и refresh.
normalizeCurrency(value). Приводит валюту к нормальному виду uppercase. Если значение пустое или битое, ставит дефолт.
updateBucketCurrency(currentCurrency, nextCurrency). Ведет валюту для конкретного KPI-блока. Если в одном блоке встретились разные валюты, переключает на MIX.
fetchDeals(). Основная загрузка и расчет:
Запрашивает сделки через
crm.deal.list(насчёт этого пункта после списка есть уточнение, потому что лучше делать по-другому).Для компании применяет серверный фильтр по
companyId.Для контакта учитывает множественные связи через
contactIds(иfallback contactId).Делит сделки на
won/lost/open.Суммирует 3 KPI и обновляет валюты KPI.
Пишет ошибку в error при падении.
Используется и как первичная загрузка, и как refresh.
💡В первой реализации
fetchDeals()запрашивал сделки черезcrm.deal.listи фильтровал ответ поCONTACT_IDилиCOMPANY_ID. Лучше делать по-другому: использовать универсальный методcrm.item.list, потому что он позволяет при необходимости делать сложные фильтры.Ещё сделка может быть привязана к нескольким контактам, и простой фильтр по полю
CONTACT_IDможет вернуть не все нужные данные — вотcrm.item.listсправится. Агент этого не знал, но мы объяснили и дали ему ссылки на документацию: Получить список элементов и Поля объектов. После этого он написал более оптимальную реализацию.
useDealStats.ts, полный код
import { ref, computed } from 'vue' import type { B24Frame } from '@bitrix24/b24jssdk' export interface DealStats { revenue: number lost: number potential: number revenueCurrency: string lostCurrency: string potentialCurrency: string loading: boolean error: string | null refresh: () => Promise<void> } /** * Composable аналитики по сделкам для вкладок контакта/компании. * Реализовано через универсальный REST-метод crm.item.list (entityTypeId=2 для сделок). */ export const useDealStats = ( b24: B24Frame, entityId: number | string, entityType: 'contact' | 'company' = 'contact' ): DealStats => { const loading = ref(false) const error = ref<string | null>(null) const revenue = ref(0) const lost = ref(0) const potential = ref(0) const revenueCurrency = ref('') const lostCurrency = ref('') const potentialCurrency = ref('') const normalizeCurrency = (value: unknown): string => { if (typeof value === 'string' && value.trim()) { return value.trim().toUpperCase() } return 'USD' } const asString = (value: unknown): string => { if (value === null || value === undefined) { return '' } return String(value) } const asNumber = (value: unknown): number => { const n = Number(value) return Number.isFinite(n) ? n : 0 } const fieldValue = (item: Record<string, any>, keys: string[]): unknown => { for (const key of keys) { if (key in item && item[key] !== undefined) { return item[key] } } return undefined } const updateBucketCurrency = ( currentCurrency: { value: string }, nextCurrency: string ) => { if (!currentCurrency.value) { currentCurrency.value = nextCurrency return } if (currentCurrency.value !== nextCurrency && currentCurrency.value !== 'MIX') { currentCurrency.value = 'MIX' } } const extractResult = (response: any): { items: any[]; next?: number } => { const data = response?.getData?.() ?? response?._data ?? {} const result = data?.result ?? {} const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [] const nextRaw = result?.next ?? data?.next const next = typeof nextRaw === 'number' ? nextRaw : undefined return { items, next } } const matchesEntity = (deal: Record<string, any>, targetId: string): boolean => { if (entityType === 'company') { const companyId = asString(fieldValue(deal, ['companyId', 'COMPANY_ID'])) return companyId === targetId } // Для контакта сделка может быть связана с несколькими контактами. const contactIdsRaw = fieldValue(deal, ['contactIds', 'CONTACT_IDS']) const contactIds = Array.isArray(contactIdsRaw) ? contactIdsRaw.map((id) => asString(id)) : [] if (contactIds.includes(targetId)) { return true } // Фолбек для порталов/ответов, где отдается одиночная связь. const singleContactId = asString(fieldValue(deal, ['contactId', 'CONTACT_ID'])) return singleContactId === targetId } const fetchDeals = async () => { try { loading.value = true error.value = null revenue.value = 0 lost.value = 0 potential.value = 0 revenueCurrency.value = '' lostCurrency.value = '' potentialCurrency.value = '' const targetId = asString(entityId) const deals: Record<string, any>[] = [] let start: number | undefined = 0 // Универсальный API сделок: crm.item.list, entityTypeId=2. // Для компании добавляем серверный фильтр, для контакта фильтруем на клиенте по contactIds. do { const filter = entityType === 'company' ? { '=companyId': targetId } : {} const response = await b24.callMethod('crm.item.list', { entityTypeId: 2, filter, select: [ 'id', 'opportunity', 'stageId', 'stageSemanticId', 'closed', 'currencyId', 'contactIds', 'contactId', 'companyId' ], start }) const chunk = extractResult(response) for (const item of chunk.items) { if (item && typeof item === 'object') { deals.push(item) } } start = chunk.next } while (typeof start === 'number') const filteredDeals = deals.filter((deal) => matchesEntity(deal, targetId)) if (filteredDeals.length === 0) { return } for (const deal of filteredDeals) { const amount = asNumber(fieldValue(deal, ['opportunity', 'OPPORTUNITY'])) const stageSemantic = asString(fieldValue(deal, ['stageSemanticId', 'STAGE_SEMANTIC'])).toUpperCase() const stageId = asString(fieldValue(deal, ['stageId', 'STAGE_ID'])).toUpperCase() const closedRaw = fieldValue(deal, ['closed', 'CLOSED']) const isClosed = closedRaw === true || closedRaw === 'Y' const currency = normalizeCurrency(fieldValue(deal, ['currencyId', 'CURRENCY_ID'])) const isWon = stageSemantic === 'SUCCESS' || stageSemantic === 'WON' || stageId === 'WON' || stageId.endsWith(':WON') const isLost = stageSemantic === 'FAILURE' || stageSemantic === 'LOSE' || stageSemantic === 'LOST' || stageId === 'LOSE' || stageId === 'LOST' || stageId.endsWith(':LOSE') || stageId.endsWith(':LOST') if (isWon && isClosed) { revenue.value += amount updateBucketCurrency(revenueCurrency, currency) } else if (isLost && isClosed) { lost.value += amount updateBucketCurrency(lostCurrency, currency) } else if (!isClosed) { potential.value += amount updateBucketCurrency(potentialCurrency, currency) } } } catch (err: any) { error.value = err?.message || 'Ошибка при загрузке данных о сделках' console.error('useDealStats error:', err) } finally { loading.value = false } } fetchDeals() return { revenue: computed(() => revenue.value), lost: computed(() => lost.value), potential: computed(() => potential.value), revenueCurrency: computed(() => revenueCurrency.value), lostCurrency: computed(() => lostCurrency.value), potentialCurrency: computed(() => potentialCurrency.value), loading: computed(() => loading.value), error: computed(() => error.value), refresh: fetchDeals } as unknown as DealStats }
dashboard-contact.client.vue и dashboard-company.client.vue (новые файлы)
Почти одинаковые файлы, которые нужны для встроек в карточки контакта и компании. Инициализируют SDK в placement-контексте и показывают KPI контакта.
Основные функции
Подключаем инструменты: B24Frame (тип SDK-объекта Bitrix24), реактивные переменные ref, computed и onMounted, готовый хелпер из Bitrix UI-пакета для dashboard-страниц.
Объявляем встройку: definePageMeta({ layout: 'placement' }) говорит Nuxt, что это страница-встройка в Bitrix, а не обычная страница сайта.
Готовим инфраструктуру до запуска логики. Описываем сущности:
useAppInit(...)— инициализация приложения и SDK-данных.useNuxtApp()и$initializeB24Frame— получаем SDK-клиент Bitrix.
Состояние страницы ref/computed хранит данные и статусы для UI.
Сущности:
$b24— экземпляр SDK.entityId— ID контакта/компании.dealStats— объект с KPI и функцией refresh.isInit— страница готова к показу.isLoading— единый флаг загрузки.
Правильно вытаскиваем ID текущей карточки чере�� resolveContactId и resolveCompanyId.
Главный запуск страницы onMounted(async () => { ... }). Включает загрузку, инициализирует SDK и запускает все шаги для корректного отображения встройки.
Кнопка обновления handleRefresh. Вызывает dealStats.refresh() и пересчитывает KPI.
Отображение данных пользователю, работает через <template>. Содержит заголовок с ID, 3 карточки с выручкой, отдельные валюты для каждого KPI, блок ошибки, кнопку обновления и состояние «Загрузка».
dashboard-contact.client.vue, полный код
<script setup lang="ts"> import type { B24Frame } from '@bitrix24/b24jssdk' import { ref, onMounted, computed } from 'vue' import { useDashboard } from '@bitrix24/b24ui-nuxt/utils/dashboard' /** * ПАСПОРТ ИЗМЕНЕНИЙ ФАЙЛА * ТИП: СОЗДАНИЕ * - Новый placement-handler для вкладки дашборда в карточке контакта. * - Подключён фронтенд-расчёт KPI через useDealStats. * УДАЛЕНИЕ: * - Легаси-блока в этом файле не было (файл создан с нуля). */ definePageMeta({ layout: 'placement' }) /** * [REPLACED BLOCK] * useI18n должен вызываться на верхнем уровне setup. * Ранее useI18n вызывался внутри onMounted, что приводило к runtime-ошибке: * "Must be called at the top of a setup function". */ const { t, locales: localesI18n, setLocale } = useI18n() const { $logger, initApp, b24Helper, destroyB24Helper, processErrorGlobal } = useAppInit('dashboard_contact') const { $initializeB24Frame } = useNuxtApp() let $b24: null | B24Frame = null let entityId = ref<number | string>('unknown') let dealStats: any = null const { contextId, isLoading: isLoadingState, load } = useDashboard({ isLoading: ref(false), load: () => {} }) const isLoading = computed({ get: () => isLoadingState?.value === true || dealStats?.loading?.value === true, set: (value: boolean) => { load?.(value, contextId) } }) const isInit = ref(false) /** * [NEW BLOCK] * Резолвер ID сущности для CRM-вкладки. * Приоритет: * 1) placement.options.ID (stable for placements) * 2) getContext().documentId fallback */ // Для CRM-вкладок основным источником ID считаем placement.options.ID, context.documentId оставляем резервным вариантом. function resolveContactId(frame: B24Frame): number | string { const placementId = frame.placement?.options?.ID if (placementId) { return placementId } const contextId = frame.getContext()?.documentId if (contextId) { return contextId } return 'unknown' } onMounted(async () => { try { isLoading.value = true $b24 = await $initializeB24Frame() // Инициализируем приложение await initApp($b24, localesI18n, setLocale) // В CRM placement обычно приходит ID в placement.options.ID, // а getContext().documentId оставляем как резервный вариант. entityId.value = resolveContactId($b24) $logger.info(`Contact ID: ${entityId.value}`, { placementOptions: $b24.placement?.options, context: $b24.getContext?.() }) if (entityId.value === 'unknown') { $logger.warn('Contact ID was not found in placement options or context') } /** * [NEW BLOCK] * Источник аналитики на фронтенде: * - useDealStats выполняет прямые REST-вызовы через официальный JS SDK. */ // Статистику сделок загружаем с фронтенда через REST SDK для текущего контакта. dealStats = useDealStats($b24, entityId.value, 'contact') // Устанавливаем размер фрейма // Нужен для корректной высоты контента внутри вкладки Bitrix24. await $b24?.parent.fitWindow() isInit.value = true } catch (error) { processErrorGlobal(error, { homePageIsHide: true, isShowClearError: true }) } finally { isLoading.value = false } }) const handleRefresh = async () => { // Явное ручное обновление KPI по нажатию пользователя. if (dealStats) { await dealStats.refresh() } } </script> <template> <div class="p-4"> <div v-if="isInit && dealStats" class="space-y-4"> <!-- Заголовок --> <div class="mb-6"> <h2 class="text-lg font-bold">Статистика сделок</h2> <p class="text-sm text-gray-500">Контакт ID: {{ entityId }}</p> </div> <!-- KPI Карточки --> <div class="grid grid-cols-3 gap-4"> <!-- Полученная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Полученная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-green-600"> {{ dealStats.revenue.value.toFixed(0) }} {{ dealStats.revenueCurrency.value || 'USD' }} </p> </div> </B24Card> <!-- Потерянная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Потерянная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-red-600"> {{ dealStats.lost.value.toFixed(0) }} {{ dealStats.lostCurrency.value || 'USD' }} </p> </div> </B24Card> <!-- Потенциальная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Потенциальная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-blue-600"> {{ dealStats.potential.value.toFixed(0) }} {{ dealStats.potentialCurrency.value || 'USD' }} </p> </div> </B24Card> </div> <!-- Ошибка (если есть) --> <div v-if="dealStats.error.value" class="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"> {{ dealStats.error.value }} </div> <!-- Кнопка обновления --> <div class="flex justify-center"> <B24Button color="air-primary" :loading="dealStats.loading.value" @click="handleRefresh" > Обновить </B24Button> </div> </div> <!-- Загрузка --> <div v-else-if="!isInit" class="text-center py-8"> <p class="text-gray-500">Загрузка...</p> </div> </div> </template>
dashboard-company.client.vue, полный код
<script setup lang="ts"> import type { B24Frame } from '@bitrix24/b24jssdk' import { ref, onMounted, computed } from 'vue' import { useDashboard } from '@bitrix24/b24ui-nuxt/utils/dashboard' /** * ПАСПОРТ ИЗМЕНЕНИЙ ФАЙЛА * ТИП: СОЗДАНИЕ * - Новый placement-handler для вкладки дашборда в карточке компании. * - Подключён фронтенд-расчёт KPI через useDealStats с фильтром COMPANY_ID. * УДАЛЕНИЕ: * - Легаси-блока в этом файле не было (файл создан с нуля). */ definePageMeta({ layout: 'placement' }) /** * [REPLACED BLOCK] * useI18n должен вызываться на верхнем уровне setup. * Ранее useI18n вызывался внутри onMounted, что приводило к runtime-ошибке: * "Must be called at the top of a setup function". */ const { t, locales: localesI18n, setLocale } = useI18n() const { $logger, initApp, b24Helper, destroyB24Helper, processErrorGlobal } = useAppInit('dashboard_company') const { $initializeB24Frame } = useNuxtApp() let $b24: null | B24Frame = null let entityId = ref<number | string>('unknown') let dealStats: any = null const { contextId, isLoading: isLoadingState, load } = useDashboard({ isLoading: ref(false), load: () => {} }) const isLoading = computed({ get: () => isLoadingState?.value === true || dealStats?.loading?.value === true, set: (value: boolean) => { load?.(value, contextId) } }) const isInit = ref(false) /** * [NEW BLOCK] * Резолвер ID сущности для CRM-вкладки. * Приоритет: * 1) placement.options.ID (стабильный источник для placement) * 2) getContext().documentId fallback */ // Для CRM-вкладок основным источником ID считаем placement.options.ID, context.documentId оставляем резервным вариантом. function resolveCompanyId(frame: B24Frame): number | string { const placementId = frame.placement?.options?.ID if (placementId) { return placementId } const contextId = frame.getContext()?.documentId if (contextId) { return contextId } return 'unknown' } onMounted(async () => { try { isLoading.value = true $b24 = await $initializeB24Frame() // Инициализируем приложение await initApp($b24, localesI18n, setLocale) // В CRM placement обычно приходит ID в placement.options.ID, // а getContext().documentId оставляем как резервный вариант. entityId.value = resolveCompanyId($b24) $logger.info(`Company ID: ${entityId.value}`, { placementOptions: $b24.placement?.options, context: $b24.getContext?.() }) if (entityId.value === 'unknown') { $logger.warn('Company ID was not found in placement options or context') } /** * [NEW BLOCK] * Источник аналитики на фронтенде: * - useDealStats выполняет прямые REST-вызовы через официальный JS SDK. */ // Статистику сделок загружаем с фронтенда через REST SDK для текущей компании. dealStats = useDealStats($b24, entityId.value, 'company') // Устанавливаем размер фрейма // Нужен для корректной высоты контента внутри вкладки Bitrix24. await $b24?.parent.fitWindow() isInit.value = true } catch (error) { processErrorGlobal(error, { homePageIsHide: true, isShowClearError: true }) } finally { isLoading.value = false } }) const handleRefresh = async () => { // Явное ручное обновление KPI по нажатию пользователя. if (dealStats) { await dealStats.refresh() } } </script> <template> <div class="p-4"> <div v-if="isInit && dealStats" class="space-y-4"> <!-- Заголовок --> <div class="mb-6"> <h2 class="text-lg font-bold">Статистика сделок</h2> <p class="text-sm text-gray-500">Компания ID: {{ entityId }}</p> </div> <!-- KPI Карточки --> <div class="grid grid-cols-3 gap-4"> <!-- Полученная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Полученная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-green-600"> {{ dealStats.revenue.value.toFixed(0) }} {{ dealStats.revenueCurrency.value || 'USD' }} </p> </div> </B24Card> <!-- Потерянная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Потерянная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-red-600"> {{ dealStats.lost.value.toFixed(0) }} {{ dealStats.lostCurrency.value || 'USD' }} </p> </div> </B24Card> <!-- Потенциальная выручка --> <B24Card variant="outline"> <div class="p-4 text-center"> <p class="text-sm text-gray-600 mb-2">Потенциальная выручка</p> <p v-if="dealStats.loading.value" class="text-xl font-bold">...</p> <p v-else class="text-2xl font-bold text-blue-600"> {{ dealStats.potential.value.toFixed(0) }} {{ dealStats.potentialCurrency.value || 'USD' }} </p> </div> </B24Card> </div> <!-- Ошибка (если есть) --> <div v-if="dealStats.error.value" class="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"> {{ dealStats.error.value }} </div> <!-- Кнопка обновления --> <div class="flex justify-center"> <B24Button color="air-primary" :loading="dealStats.loading.value" @click="handleRefresh" > Обновить </B24Button> </div> </div> <!-- Загрузка --> <div v-else-if="!isInit" class="text-center py-8"> <p class="text-gray-500">Загрузка...</p> </div> </div> </template>
Что будем делать дальше
Сейчас у нас есть простой проект для ознакомления с работой AI-стартера, выбора подходящего агента и простой тренировки интеграции локального приложения в портал. Даже в этот простой проект можно внести много модификаций:
Конвертировать все сделки с разными валютами в одну базовую валюту. Например, считать через курс ЦБ и показывать рядом исходные суммы.
Добавить дашборд воронки по стадиям сделок и по периодам.
Разбить сделки по менеджерам.
Подключить бэкенд-интеграции и добавить очереди сообщений для ускорения загрузки вкладок.
Установить уведомления и алерты при резком падении конверсии или росте потерь и зависших сделок.
Напишите в комментариях, каких возможностей вам не хватает, а мы постараемся их реализовать в следующих статьях.
