Как написать виджет amocrm

Разработка script.js

Разберем общую структуру script.js:

Данная часть виджета состоит из основных обязательных частей, которые мы рассмотрим. Так же script.js может содержать дополнительные функции. Разберем начальный каркас данного файла.

Весь виджет представляется в виде объекта. Когда система загружает виджеты, она расширяет существующий системный объект Widget функционалом описанным в script.js. Таким образом объект CustomWidget наследует свойства и методы, которые будут полезны для работы и разобраны далее. Объект имеет функции обратного вызова, которые вызываются при определенных условиях. Данные функции перечислены в таблице после примера кода script.js.

Общий вид script.js

define(['jquery'], function ($) {
  var CustomWidget = function () {
    var self = this, // для доступа к объекту из методов
      system = self.system(), //Данный метод возвращает объект с переменными системы.
      langs = self.langs;  //Объект локализации с данными из файла локализации (папки i18n)

    this.callbacks = {
      settings: function () {},
      init: function () {
        return true;
      },
      bind_actions: function () {
        return true;
        },
      render: function () {
        return true;
        },
      dpSettings: function () {},
      advancedSettings: function () {},
      destroy: function () {},
      contacts: {
        selected: function () {}
      },
      onSalesbotDesignerSave: function (handler_code, params) {},
      leads: {
        selected: function () {}
      },
      todo: {
        selected: function () {}
      },
      onSave: function () {},
      onAddAsSource: function (pipeline_id) {}
    };
    return this;
  };
  return CustomWidget;
});

Функции обратного вызова, объект callbacks

Функция Описание
render: При сборке виджета первым вызывается callbacks.render.В этом методе обычно описываются действия для отображения виджета.Виджет будет отображаться самостоятельно только в меню настроек (settings), для отображения виджета в других областях, например в правой колонке, необходимо использовать специальные методы в этой функции, например методы объекта render() и/или render_template(), которые разобраны далее. Необходимо чтобы callbacks.render вернул true. Это важно, т.к. без этого, не запустятся методы callbacks.init и callbacks.bind_actions.
init: Запускается сразу после callbacks.render одновременно с callbacks.bind_actions.Метод init() обычно используется для сбора необходимой информации и других действий, например связи с сторонним сервером и авторизации по API, если виджет используется для передачи или запроса информации стороннему серверу.В самом простом случае он может ,к примеру,определять текущую локацию, где находится пользователь.callbacks.init должен возвращать true для дальнейшей работы.
bind_actions: Метод callbacks.bind_actions используется для навешивания событий на действия предпринимаемые пользователем, например нажатие пользователя на кнопку. callbacks.bind_actions должен возвращать true.
settings: Метод callbacks.settings вызывается при щелчке на иконку виджета в области настроек.Может использоваться для добавления на страницу модального окна, подробнее это рассмотрено далее.
Публичные виджет не должны никак скрывать/влиять на рейтинг и отзывы виджета.
Для всех виджетов, которые загружены в интеграции, запрещены виртуальные клики на кнопку установить.
dpSettings: Метод callbacks.dpSettings аналогичен callbacks.settings, но вызывается в области видимости digital_pipeline (подробнее Digital pipeline)
advancedSettings: Метод callbacks.advancedSettings вызывается на странице расширенных настроек виджета. Для функционирования данного callback’a необходимо указать область подключения виджета advanced_settings.
onSave: callbacks.onSave вызывается при щелчке пользователя на кнопке “Установить/Сохранить” в настройках виджета. Можно использовать для отправки введенных в форму данных и смены статуса виджета. Так же этот метод срабатывает при отключении виджета. Сначала срабатывает onSave, затем destroy.
leads:selected Данная функция вызывается в случае выбора элементов списка сделок, с использованием checkbox, и последующем нажатии на имя виджета в добавочном меню. Используется, когда нужно предпринять какие-либо действия с выделенными объектами. Примеры рассмотрены далее.
contacts:selected Данная функция вызывается в случае выбора элементов списка контактов, с использованием checkbox, и последующем нажатии на имя виджета в добавочном меню. Используется, когда нужно предпринять какие-либо действия с выделенными объектами. Примеры рассмотрены далее.
todo:selected Данная функция вызывается в случае выбора элементов списка задач, с использованием checkbox, и последующем нажатии на имя виджета в добавочном меню. Используется, когда нужно предпринять какие-либо действия с выделенными объектами. Примеры рассмотрены далее.
destroy: Данная функция вызывается при отключении виджета через меню его настроек. Например нужно удалить из DOM все элементы виджета, если он был отключен, или предпринять еще какие-либо действия. Так же данный метод срабатывает при переходе между областями отображения виджета.
onSource: Данная функция определяет логику работы источника и вызывается если был использован какой то источник. Например если пользователь отправил sms.
onSalesbotDesignerSave: Данная функция определяет логику работы действия виджета в Salesbot и вызывается, если был добавлен виджет в конструкторе Salesbot, при сохранении.
onAddAsSource: Данная функция вызывается при добавлении виджета как источника в настройках воронки.

Пример JS-кода виджета:

Приведенный ниже пример демонстрирует варианты использования объекта функций обратного вызова с дополнительными функциями, а так же применение некоторых функций объекта виджет. Все эти функции рассмотрены в примерах далее.Советуем просто просмотреть данный код, а за подробностями обратиться к описанию функций объекта widget.

Данный виджет будет выбирать из листа контактов отмеченные контакты и передавать телефоны и e-mail адреса на сторонний сервер.

Функции, используемые в данном примере разобраны более подробно далее. В первую очередь стоит обратить внимание на объект callbacks.

define(['jquery'], function ($) {
    var CustomWidget = function () {
        var self = this,
            system = self.system;

        this.get_ccard_info = function () //Сбор информации из карточки контакта
        {
            if (self.system().area == 'ccard') {
                var phones = $('.card-cf-table-main-entity .phone_wrapper input[type=text]:visible'),
                    emails = $('.card-cf-table-main-entity .email_wrapper input[type=text]:visible'),
                    name = $('.card-top-name input').val(),
                    data = [],
                    c_phones = [], c_emails = [];
                data.name = name;
                for (var i = 0; i < phones.length; i++) {
                    if ($(phones[i]).val().length > 0) {
                        c_phones[i] = $(phones[i]).val();
                    }
                }
                data['phones'] = c_phones;
                for (var i = 0; i < emails.length; i++) {
                    if ($(emails[i]).val().length > 0) {
                        c_emails[i] = $(emails[i]).val();
                    }
                }
                data['emails'] = c_emails;
                console.log(data)
                return data;
            } else {
                return false;
            }
        };

        this.sendInfo = function (person_name, settings) { // Отправка собранной информации
            self.crm_post(
                'http://example.com/index.php',
                {
                    // Передаем POST данные
                    name: person_name['name'],
                    phones: person_name['phones'],
                    emails: person_name['emails']
                },
                function (msg) {
                },
                'json'
            );
        };
        this.callbacks = {
            settings: function () {
            },
            dpSettings: function () {
            },
            init: function () {
                if (self.system().area == 'ccard') {
                    self.contacts = self.get_ccard_info();
                }
                return true;
            },
            bind_actions: function () {
                if (self.system().area == 'ccard' || 'clist') {
                    $('.ac-form-button').on('click', function () {
                        self.sendInfo(self.contacts);
                    });
                }
                return true;
            },
            render: function () {
                var lang = self.i18n('userLang');
                w_code = self.get_settings().widget_code; //в данном случае w_code='new-widget'
                if (typeof (AMOCRM.data.current_card) != 'undefined') {
                    if (AMOCRM.data.current_card.id == 0) {
                        return false;
                    } // не рендерить на contacts/add || leads/add
                }
                self.render_template({
                    caption: {
                        class_name: 'js-ac-caption',
                        html: ''
                    },
                    body: '',
                    render: '
       <div class="ac-form">
           <div id="js-ac-sub-lists-container">
           </div>
           <div id="js-ac-sub-subs-container">
           </div>
           <div class="ac-form-button ac_sub">SEND</div>
       </div>
       <div class="ac-already-subs"></div>
       <link type="text/css" rel="stylesheet" href="/widgets/' + w_code + '/style.css" >'
                });
                return true;
            },
            contacts: {
                selected: function () {    //Здесь описано поведение при мультивыборе контактов и клике на название виджета
                    var c_data = self.list_selected().selected;

                    $('#js-sub-lists-container').children().remove(); //Контейнер очищается затем в контейнер собираются элементы, выделенные в списке.контейнер - div блок виджета, отображается в правой колонке.
                    var names = [], // Массив имен
                        length = c_data.length; // Количество выбранных id (отсчет начинается с 0)
                    for (var i = 0; i < length; i++) {
                        names[i] = {
                            emails: c_data[i].emails,
                            phones: c_data[i].phones
                        };
                    }
                    console.log(names);
                    for (var i = 0; i < length; i++) {
                        $('#js-ac-sub-lists-container').append('<p>Email:' + names[i].emails + ' Phone:' + names[i].phones + '</p>');
                    }
                    $(self.contacts).remove(); //очищаем переменную
                    self.contacts = names;
                }
            },
            leads: {
                selected: function () {

                }
            },
            onSave: function () {

                return true;
            }
        };
        return this;
    };
    return CustomWidget;
});

Содержание файла new_widget.css, который может находиться в папке с виджетом

.card-widgets__widget-new_widget .card-widgets__widget__body {
    padding: 0 10px 0px;
    padding-bottom: 5px;
    background-color: grey;
}

.ac-form {
    padding: 5px 15px 15px;
    margin-bottom: 10px;
    background: #fff;
}

.js-ac-caption {
    display: block;
    margin: auto;
    background-color: grey;
}

.lists_amo_ac ul li span {
    color: #81868f;
}

.ac-form-button {
    padding: 5px 0;
    background: #fafafb;
    text-align: center;
    font-weight: bold;
    text-transform: uppercase;
    border: 1px solid rgba(0, 0, 0, 0.09);
    -webkit-box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
    -webkit-border-radius: 2px;
    border-radius: 2px;
    font-size: 13px;
    cursor: pointer;
}

.ac-form-button:active {
    background: grey;
}

.ac-already-subs {
    position: absolute;
    width: 245px;
    bottom: 10px;
    right: 15px;
    cursor: pointer;
    color: #f37575;
    background: #fff;
}

#js-ac-sub-lists-container, #js-ac-sub-subs-container {
    min-height: 38px;
}

Методы объекта widget

Метод render()

Метод render() используется, для работы с шаблонами шаблонизатора twig.js, который удобен в использовании, ознакомиться с документацией можно по ссылке.

Метод является оборачивающим для twig.js и принимает в качестве параметров информацию по шаблону(data) и данные для рендеринга данного шаблона (params). render(data, params). Метод возвращает отрендеренный шаблон. result = twig(data).render(params).

Разберем простой Пример:

var params = [
    {
        name: 'name1',
        id: 'id1'
    },
    {
        name: 'name2',
        id: 'id2'
    },
    {
        name: 'name3',
        id: 'id3'
    }
]; //массив данных, передаваемых для шаблона

var template = '<div><ul>' + '{% for person in names %}' +
	'<li>Name : {{ person.name }}, id: {{ person.id }}</li>' +
'{% endfor %}' + '</ul></div>';

console.log(self.render({data: template}, // передаем шаблон
    {names: params}));

В результате мы получим разметку :

  • Name : name1, id: id1
  • Name : name2, id: id2
  • Name : name3, id: id3

Можно передать функции один из шаблонов нашей системы, для этого в передаваемом объекте data нужно указать ссылку на шаблон: ref: ‘/tmpl/controls/#TEMPLATE_NAME#.twig. Например для создания раскрывающегося списка используем существующий шаблон:

m_data = [
    {
        option: 'option1',
        id: 'id1'
    },
    {
        option: 'option2',
        id: 'id2'
    },
    {
        option: 'option3',
        id: 'id3'
    }
]; //массив данных, передаваемых для шаблона

var data = self.render(
    {ref: '/tmpl/controls/select.twig'}, // объект data в данном случае содержит только ссылку на шаблон
    {
        items: m_data,      //данные
        class_name: 'subs_w',  //указание класса
        id: w_code + '_list'   //указание id
    });

Чтобы посмотреть на разметку data, надо добавить data в DOM. Разметка раскрывающегося списка выполнена в стиле нашей системы.

Ознакомится с полным списком шаблонов, вы можете пройдя по ссылке. Для использования других системных шаблонов нужно поменять параметр ref, общий вид: ref: ‘/tmpl/controls/#TEMPLATE_NAME#.twig’

  • textarea
  • suggest
  • select
  • radio
  • multiselect
  • date_field
  • checkbox
  • checkboxes_dropdown
  • file
  • button
  • cancel_button
  • delete_button
  • input

Методу render() можно передавать не только сслыки системные существующие шаблоны, но и ссылки на собственные шаблоны.Для этого надо передавать объект data с рядом параметров. Необходимо создать папку templates в папке нашего виджета и положить в нее шаблон template.twig. Рассмотрим пример:

var params = {}; //пустые данные
var callback = function (template) { //функция обратного вызова,вызывается если шаблон загружен, ей передается объект шаблон.
    var markup = template.render(params); //
    /*
    * далее код для добавления разметки в DOM
    */
};
var s = self.render({
        href: 'templates/template.twig', //путь до шаблона
        base_path: self.params.path, //базовый путь до директории с виджетом
        load: callback //вызов функции обратного вызова произойдет только если шаблон существует и загружен
    },
    params
); //параметры для шаблона

Если шаблон существует по адресу ссылки, то вызывается переданная функция callback, и ей передается объект шаблон, который содержит метод render, передаем render параметры для рендеринга. В данном примере вызов функции обратного вызова произойдет, если шаблон существует в папке.

Пример функции для загрузки шаблонов по из папки templates

Для удобства обращения создадим функцию. В нее будем передавать параметры: template – имя шаблона который лежит в папке с виджетом в папке template, params – объект параметров для шаблона, callbacks – функция обратного вызова, которая будет вызываться после загрузки шаблона, в данном случае будем добавлять шаблон в модальное окно. Про объект модальное окно можно почитать в разделе JS методы и объекты для работы с amoCRM.

Общий пример работы с функцией self.getTemplate

self.getTemplate = function (template, params, callback) {
    params = (typeof params == 'object') ? params : { } ;
    template = template || '';

    return self.render({
        href: '/templates/' + template + '.twig',
        base_path: self.params.path, //тут обращение к объекту виджет вернет /widgets/#WIDGET_NAME#
        load: callback //вызов функции обратного вызова
    }, params); //параметры для шаблона
};

Пример использования self.getTemplate в callbacks.settings


settings: function ( ) {
    self.getTemplate (  //вызов функции
        'login_block', //указываем имя шаблона, который лежит у нас в папке с виджетом в папке templates
        {}, /* пустые данные для шаблона, т.к мы сначала запросим шаблон, если он существует, то функция обр.вызова вызовет уже функцию для добавления данных к шаблону, см.ниже */
        function (template) {
            template.render ({
                widget_code: self.params.widget_code, //параметры для шаблона.
                lang: self.i18n ( 'settings' )}));
}};

Добавление тура в модальное окно виджета

Для добавления тура в модальное окно виджета необходимо добавить свойство tour в manifest.json. Подробней об этом читайте в этой статье.

Пример описания свойства в manifest.json:


  "tour": {
    "is_tour": true,
    "tour_images": {
      "ru": [
        "/images/tour_1_ru.png",
        "/images/tour_2_ru.png",
        "/images/tour_3_ru.png"
      ],
      "en": [
        "/images/tour_1_en.png",
        "/images/tour_2_en.png",
        "/images/tour_3_en.png"
      ],
      "es": [
        "/images/tour_1_es.png",
        "/images/tour_2_es.png",
        "/images/tour_3_es.png"
      ]
    },
    "tour_description": "widget.tour_description"
  },

Метод render_template()

Метод render_template() оборачивает переданную ему разметку или шаблон в стандартную для виджетов оболочку (разметку) и помещает полученную разметку в правую колонку виджетов

Можно передавать данной функции html разметку или шаблон с данными для рендеринга, так же как в случае с методом render().

Функция дополняет переданную ей разметку своей, хранящейся в переменной template_element объекта widget.

/*html_data хранит разметку, которую необходимо поместить в правую колонку виджетов.*/
var html_data = '<div id="w_logo" class="nw_form">' + '<div id="js-sub-subs-container">' + '</div>' + '<div class="nw-form-button">BUTTON</div></div>' + '<div class="already-subs"></div>'; self.render_template({ caption: { class_name: 'new_widget', //имя класса для обертки разметки }, body: html_data, //разметка render: '' //шаблон не передается });

Был показан самый простой пример без использования шаблона, но метод render_template() так же может принимать шаблон и данные для шаблона в качестве параметров. Так же можно передавать сылку на шаблон, аналогично методу render().

/*Здесь в качестве параметров передается шаблон и данные для шаблона.*/
var render_data = '<div class="nw_form">' +
    '<div id="w_logo">' +
    '<img src="/widgets/{{w_code}}/images/logo.png" id="firstwidget_image"></img>' +
    '</div>' +
    '<div id="js-sub-lists-container">' +
    '</div>' +
    '<div id="js-sub-subs-container">' +
    '</div>' +
    '<div class="nw-form-button">{{b_name}}</div></div>' +
    '<div class="already-subs"></div>';

self.render_template(
    {
        caption: {
            class_name: 'new_widget'
        },
        body: '',
        render: render_data
    },
    {
        name: "widget_name",
        w_code: self.get_settings().widget_code,
        b_name: "BUTTON" // в данном случае лучше передать ссылку на lang через self.i18n()
    }
);

Получаем в правой колонке виджет, созданный по шаблону.

Объект widget имеет еще ряд полезных функций, которые можно вызывать для решения разных задач.

Описание и примеры приведены ниже.

Функция set_lang()

Функция set_lang() позволяет изменять параметры, установленные по умолчанию файлами из папки i18n

Текущий объект lang хранится в переменной langs объекта widget

langs = self.langs; //Вызываем текущий объект
langs.settings.apiurl = 'apiurl_new' //меняем имя поля
self.set_lang(langs);       //меняем текущий объект на объект с измененным полем
console.log(self.langs); //выводим в консоль, чтобы убедиться, что название изменилось

Функция set_settings()

Функция set_settings() позволяет добавлять виджету свойства.

self.set_settings({par1: "text"}); //создается свойство с именем par1 и значением text
self.get_settings(); // в ответ придет массив с уже созданным свойством

Функция list_selected()

Функция list_selected() возвращает выделенные галочками контакты/сделки из таблицы контактов/сделок в виде массива объектов: count_selected и selected. Один из объектов selected содержит массив выделенных галочками объектов с свойствами emails, id, phones, type.

console.log(self.list_selected().selected); //Возвращает два объекта, выбираем объект selected
//Результат:
/*0: Object
emails: Array[1]
id: #id#
phones: Array[1]
type: "contact" */

Функция widgetsOverlay()

Функция widgetsOverlay() (true/false) включает или отключает оверлей, который появляется при вызове виджета из списка контактов или сделок.

//Пример:
self.widgetsOverlay(true);

Функция add_action()

При работе пользователя в области список контактов и компаний, можно обеспечить вызов какой-либо функции, при щелчке на номер телефона или e-mail адрес контакта.

Функции add_action() передаются параметры (type,action), где type – “e-mail” или “phone”, action – функция, которая будет вызываться при щелчке на номере телефона или адресе e-mail.

self.add_action("phone", function () {
    /*
    * код взаимодействия с виджетом телефонии
    */
});

Функция add_source()

Позволяет указать новый источник, который будет отображаться в контроле в нижней части фида карточки сделки, покупателя, контакта или компании.

На данный момент можно указать только один тип источника – sms

Функции add_source() передаются параметры (source_type, handler), где source_type – “sms”, handler – функция, которая будет вызываться при клике на кнопку “отправить”.

Функция “handler” всегда должна возвращать объект Promise

// пример

self.add_source("sms", function (params) {
    /*
    params - это объект в котором будут  необходимые параметры для отправки смс

    {
    "phone": 75555555555,   // телефон получателя
    "message": "sms text",  // сообщение для отправки
    "contact_id": 12345     // идентификатор контакта, к которому привязан номер телефона
    }
    */

    return new Promise(function (resolve, reject) {
        // тут будет описываться логика для отправки смс

        $.ajax({
            url: '/widgets/' + self.system().subdomain + '/loader/' + self.get_settings().widget_code + '/send_sms',
            method: 'POST',
            data: params,
            success: function () {
                // при успешном завершении будет автоматически создано примечание типа 'sms'
                resolve();
            },
            error: function () {
                reject();
            }
        });
    });
});

Функция set_status()

Виджет может иметь один из трех статусов. Статус виджета отображен в области settings, на иконке виджета. В случае, если виджет использует данные, введенные пользователем для API стороннего сервиса, и эти данные введены неверно, то можно использовать данную функцию для отображения статуса error.

Доступны статусы install(виджет не активен) и installed (виджет активен), error (виджет в состоянии ошибки).

//Пример:
self.set_status('error');

Метод crm_post(url, data, callback, type)

Метод используется для отправки запроса на ваш удаленный сервер через прокси-сервер amoCRM. Его использование необходимо, т.к. при работе с amoCRM пользователь работает по защищенному SSL протоколу и браузер может блокировать кросс-доменные запросы. Лучшем решением является наличие подписанного SSL-сертификата на стороне внутренней системы и работа по HTTPS. Функция аналогична jQuery post().

Описание метода

Параметр Тип Описание
url Строка Ссылка на скрипт обрабатывающий данные
data
optional
Javascript объект Пары ключ:значение, которые будут отосланы на сервер
callback
optional
Функция Функция, вызывающаяся после каждого успешного выполнения (в случае передачи type=text or html, выполняется всегда).
type
optional
Строка Тип данных, который возвращается функции: “xml”, “html”, “script”, “json”, “jsonp”, или “text”.

Пример запроса

self.crm_post(
    'http://www.test.ru/file.php',
    {
        // Передаем POST данные с помощью объектной модели Javascript
        name: 'myname',
        login: 'mylogin',
        password: 'mypassword'
    },
    function (msg) {
        alert('It's all OK');
    },
    'text',
    function () {
        alert('Error');
    }
)

Метод self.get_settings()

Данный метод необходим, для того, чтобы получить данные, которые ввел пользователь при подключении виджета. Данные возвращаются в виде объекта javascript

Пример ответа:

{
    login: "ZABRTEST" ,
    password: "test" ,
    maybe: "Y"
}

Метод self.get_version()

Данный метод вернет номер версии виджета, можно использовать для того, чтобы кэш статики сбрасывался после обновления. Данные возвращаются в виде строки.

Пример ответа:

Метод self.get_install_status()

Данный метод вернет статус установки виджета. Данные возвращаются в виде строки. Возможные значения – installed (виджет устновлен), install (виджет не установлен), not_configured (тур виджета пройден, но настройки не заполнены)

Пример ответа:

Метод self.system()

Данный метод необходим, для того, чтобы получить системные данные. Данные возвращаются в виде объекта javascript

Параметр Описание
area Область на которой воспроизводится виджет в данный момент
amouser_id Id пользователя
amouser Почта пользователя
amohash Ключ для авторизации API

Пример ответа:

{
    area: "ccard" ,
    amouser_id: "103586" ,
    amouser: "testuser@amocrm.ru" ,
    amohash: "d053abd66063225fa8b763afz6496da8"
}

Метод self.i18n(objname)

Данные метод позволяет, получить объект из языковых файлов, в котором будут сообщения на языковых локалях, используемые пользователем
В objname передается имя объекта, который необходимо вытащить

Например, вызываем функцию self.i18n('userLang')

Пример ответа:

{
    firstWidgetText: "Кликни на кнопку, чтобы переслать данные на сторонний сервер:",
    textIntoTheButton: "Отправить данные",
    responseMessage: "Ответ сервера :",
    responseError: "Ошибка"
}

Таким образом, имея простой инструмент для взаимодействия с DOM и выполнения кроссдоменных запросов, вы можете помимо создания простых текстовых виджетов, менять дизайн элементов страницы, создавать собственные информационные блоки на основе внешних данных, или наоборот, пересылать данные во внешние сервисы, причем все это работает сразу для всех пользователей вашего аккаунта.

Метод проверки согласия на передачу данных в виджете.

Данное решение не является единственно возможным.

1. В manifest.json в параметр settings добавляется кастомное поле, обязательное для заполнения, например, oferta

"oferta": {
        "name": "settings.oferta",
        "type": "custom",
        "required": true
    }

Данное поле будет скрыто(по умолчанию). При нажатии на “Установить” установка не произойдёт, так как это поле является незаполненным на данном этапе. Далее необходимо вставить блок с чекбоксом (вёрстка) в правую часть модального окна виджета и при отметке чекбокса записывать в поле oferta произвольное значение, а при снятии галочки чекбокса, очищать значение. Кроме того, следует информировать пользователя о необходимости дать согласие на передачу данных до начала установки виджета. Если пользователь нажимает кнопку “Установить” не дав согласие, выводить предупреждающее сообщение. Для примера вы можете воспользоваться следующим кодом.

Вёрстку с чекбоксом расположим в шаблоне templates/oferta.twig

templates/oferta.twig

<div class = "oferta">
    <label for = "oferta_check" class = "oferta_title">Подтвердите согласие на передачу данных аккаунта на сторонний сервер</label>
    <input type= "checkbox" name= "oferta_check" />
    <div class = "oferta_error hidden" >Необходимо дать согласие</div>
</div>

Script.js

define(['jquery'], function ($) {
    var CustomWidget = function () {
        var self = this;
        // Добавим метод получения шаблонов twig
        self.getTemplate = function (template, params, callback) {
            params = (typeof params == 'object') ? params : {};
            template = template || '';

            return self.render({
                href: '/templates/' + template + '.twig', // путь до шаблона
                base_path: self.params.path, //тут обращение к объекту виджет вернет /widgets/#WIDGET_NAME#
                load: callback //вызов функции обратного вызова
            }, params); //параметры для шаблона
        }

        this.callbacks = {
            render: function () {
                return true;
            },
            init: function () {
                return true;
            },
            bind_actions: function () {

                return true;
            },
            settings: function ($modal_body) { //$modal_body - jquery-объект блока правой части модального окна виджета

                self.getTemplate(
                    'oferta',
                    {},
                    function (template) {
                        $modal_body.find('input[name="oferta"]').val(''); // очищаем принудительно поле oferta
                        $modal_body.find('.widget_settings_block').append(template.render()); // отрисовываем шаблон и добавляем в блок настроек виджета
                        var $install_btn = $('button.js-widget-install'),
                            $oferta_error = $('div.oferta_error');
                        $modal_body.find('input[name="oferta_check"]').on('change', function (e) {

                            var $checkbox = $(e.currentTarget);
                            if ($checkbox.prop('checked')) {
                                $modal_body.find('input[name="oferta"]').val('1'); //заполняем поле oferta, если чекбокс отмечен
                                $oferta_error.addClass('hidden'); // скрываем предупреждение, если оно отображено
                            } else {
                                $modal_body.find('input[name="oferta"]').val(''); // очищаем поле oferta, если не отмечен чекбокс
                            }
                        });
                        //при нажатии на кнопку "Установить", если не отмечен чекбокс, отображаем предупреждение
                        $install_btn.on('click', function () {
                            if (!$modal_body.find('input[name="oferta"]').val()) {
                                $oferta_error.removeClass('hidden');
                            }
                        });
                    }
                );
                return true;
            },
            onSave: function () {
                return true;
            },
            destroy: function () {
                return true;
            },
            contacts: {
                selected: function () {
                    return true;
                }
            },
            leads: {
                selected: function () {
                    return true;
                }
            },
            tasks: {
                selected: function () {
                    return true;
                }
            }
        };
        return this;
    };

    return CustomWidget;
});

Для примера с получением настроек виджета в дочернем модуле рассмотрим следующий кейс. Виджет при инициализации проверяет логин на корректность и если логин не подходит по каким-то критериям, то мы должны показать пользователю модальное окно с ошибкой.

Состав нашего виджета будет следующим:

  • script.js – обработчик виджета
  • ./lib/settings_helper.js – наш хелпер, который будет хранилищем настроек
  • ./lib/error.js – вспомогательная библиотека для отображения ошибок, в ней нам надо получить логин пользователя из настроек виджета

Как видим в примере, в конструкторе виджета мы запоминаем настройки виджета в нашем специализированным хелпере, до того как виджет выполнит какой-либо из системных колбэков.

Таким образом мы можем получить хоть код виджета, хоть любой другой параметр, который находится в объекте, возвращаемом от get_settings.

script.js

define(['jquery', './lib/settings_helper', './lib/error'], function($, settings_helper, error_lib) {
    var CustomWidget = function() {
        var self = this;

        // еще в конструкторе виджета запомним его настройки в хелпере
        settings_helper.set(self.get_settings());

        this.callbacks = {
            settings: function() {},
            init: function() {
                var in_case_of_login_error = true;

                if (in_case_of_login_error) {
                    error_lib.showLoginError();
                }

                return true;
            },
            bind_actions: function() {
                return true;
            },
            render: function() {
                return true;
            },
            dpSettings: function() {},
            advancedSettings: function() {},
            destroy: function() {},
            contacts: {
                selected: function() {}
            },
            leads: {
                selected: function() {}
            },
            onSave: function() {}
        };
        return this;
    };
    return CustomWidget;
});

lib/settings_helper.js

define([], function() {
    var settings = {};

    return {
        set: function(widget_settings) {
            settings = widget_settings;
        },
        get: function() {
            return settings;
        }
    };
});

lib/error.js

define(['./lib/components/base/modal', './lib/settings_helper'], function(Modal, settings_helper) {
    return {
        showLoginError: function() {
            var widget_settings = settings_helper.get();

            new Modal()._showError(widget_settings.login + ' is incorrect!');
        }
    };
});

Для избежания случаев кэширования файла style.css необходимо при подключении файла css передавать параметром версию виджета

Ниже пример подключения файла css:

script.js

define(['jquery'], function ($) {
  var CustomWidget = function () {
    var self = this, // для доступа к объекту из методов
      system = self.system(), //Данный метод возвращает объект с переменными системы.
      langs = self.langs;  //Объект локализации с данными из файла локализации (папки i18n)

    this.callbacks = {
      settings: function () {},
      init: function () {
          // Возвращаем настройки виджета
          var settings = self.get_settings();
         //   Проверяем подключен ли наш файл css
        if ($('link[href="' + settings.path + '/style.css?v=' + settings.version +'"').length < 1) {
             //  Подключаем файл style.css передавая в качестве параметра версию виджета
             $("head").append('<link href="' + settings.path + 'style.css?v=' + settings.version + '" type="text/css" rel="stylesheet">');
        }
        return true;
      },
      bind_actions: function () {
        return true;
        },
      render: function () {
        return true;
        },
      dpSettings: function () {},
      advancedSettings: function () {},
      destroy: function () {},
      contacts: {
        selected: function () {}
      },
      leads: {
        selected: function () {}
      },
      onSave: function () {}
    };
    return this;
  };
  return CustomWidget;
});

Статья является естественным продолжением материала Шумейко записывают, или мучения работодателя, но описывает на сей раз не подробности получения черной зарплаты в компании NeuroCRM, а, собственно, сам процесс создания виджетов amoCRM — вполне вероятно, данная информация окажется весьма полезной начинающим кодерам, которые хотели бы познакомиться с самыми что ни на есть началами процесса разработки виджетов amoCRM. Хотя начать знакомство с amoCRM автор рекомендует, несомненно, с внимательного чтения технической документации.

Это была прелюдия, а теперь к делу. Итак, мы с вами загрузили базовый виджет из соответствующего раздела документации amoCRM и с интересом разглядываем содержимое архива widget.zip; ок, ну а что же дальше? Как создать свой собственный виджет, предположим, некую формочку, которая по нажатию submit станет обрабатывать введенные пользователем данные?

Спрашиваете — отвечаю: все несложно. Собственно, для того, чтобы инсталлировать полученный виджет в своей учетке amoCRM (две недели, к слову сказать, бесплатного полнофункционального тестового периода — пробуем) нам с вами придется всего лишь изменить две строчки в файле manifest.json: «code» и «secret_key», заменив дефолтные значения на свои собственные, характерные только для нашей учетной записи — создаем новый виджет, открыв вкладку API, обзываем как душе угодно (это и будет «code», но название должно быть уникальным, учтите), и получаем для него тут же сгенерированный «secret_key». С этим все просто, но необходимо ведь добавить какой-то HTML, а также обработчик?

Начну с утверждения: handler.php виджета amoCRM вы, вероятнее всего, вынуждены будете использовать на каком-то удаленном сервере, т.к. использовать php непосредственно в виджете допустимо только в случае создания публичного, прошедшего модерацию, виджета; т.е. виджета, доступного всем без исключения зарегистрированным пользователям amoCRM. В большинстве случаев, поскольку речь идет о коммерческой разработке, этот вариант неприемлем… ок, таким образом, начнем мы с вами с создания HTML-формы, способной выглядеть вот таким, например, образом:

<html>
<head>
<meta charset="utf-8">
  <script type="text/javascript"
    src="http://ajax.googleapis.com/ajax/libs/prototype/1.6.0.2/prototype.js"></script>
</head>
<body>
<form id="form">
<table width="30%" cellspacing="0" cellpadding="4">
 <tr><td>Item category</td><td width="80%"><input type="text" name="product_type" value="Наружная реклама" style="width:100%"></td></tr>
 <tr><td>Item</td><td><input type="text" name="product" value="Световой короб" style="width:100%" ></td></tr>
  <tr><td>Item type</td><td>
  <select id="test" name="item_type" style="width:100%" > 
   <option value="">-------</option> 
    <option value="square_lightbox">Прямой световой короб</option> 
    <option value="composite_lightbox">Композитный световой короб</option> 
    <option value="shaped_lightbox">Фигурный клееный световой короб</option>
    <option value="shaped_twosided_lightbox">Двусторонний фигурный световой короб</option> 
    <option value="click_data_lightbox">Лайтбокс со сменной информацией Click</option> 
</select>
  </td></tr>
  <tr><td>Height</td><td><input type="text" placeholder="Введите значение" name="height" style="width:100%"></td></tr>
  <tr><td>Width</td><td><input type="text" placeholder="Введите значение" name="width" style="width:100%"></td></tr>
</table>
<input type="button" onclick="dosubmit()" value="Submit">
</form>
<div id="result" style="padding:5px;">
</div>
<script>
function dosubmit( ) {
  new Ajax.Updater( 'result', 'handler.php', { method: 'get',
    parameters: $('form').serialize() } );
  $('form').reset();
}
</script>
</body>
</html>

В скобках отметим, что использование prototype.js в своем коде в теории вполне здесь допустимо, но на практике вызывает конфликт с априори подключенным jquery.js, поэтому не рекомендуется. Ок, а теперь попробуем интегрировать свой HTML в файл script.js виджета, получаем следующее:

define(['jquery'], function ($) {
    var CustomWidget = function () {
        var self = this, system = self.system;
        this.callbacks = {
            settings: function () {

            }, init: function () {
                /*$.getScript("http://ajax.googleapis.com/ajax/libs/prototype/1.6.0.2/prototype.js", function (data, textStatus, jqxhr) {
                 });*/
                return true;
            },
            bind_actions: function () {

                $('.print-calc-form-button').on('click', function () {
                    $('.mgc-template-modal').remove();

                    $calc_container = '<form id="form"> <table width="30%" cellspacing="0" cellpadding="4"> <tr><td>Item category</td><td width="80%"><input type="text" name="product_type" value="Outer ads" style="width:100%"></td></tr> <tr><td>Item</td><td><input type="text" name="product" value="Lightbox" style="width:100%" ></td></tr> <tr><td>Item type</td><td> <select id="test" name="item_type" style="width:100%" > <option value="">-------</option> <option value="square_lightbox">Прямой световой короб</option> <option value="composite_lightbox">Композитный световой короб</option> <option value="shaped_lightbox">Фигурный клееный световой короб</option> <option value="shaped_twosided_lightbox">Двусторонний фигурный световой короб</option> <option value="click_data_lightbox">Лайтбокс со сменной информацией Click</option> </select> </td></tr> <tr><td>Height</td><td><input type="text" name="height" style="width:100%"></td></tr> <tr><td>Width</td><td><input type="text" name="width" style="width:100%"></td></tr> </table> <input type="button" value="Submit" class="submit-button"> </form> <div id="result" style="padding:5px;"> </div>';

                    $('body').append('<div class="modal modal-list modal_print-calc modal_calc-action mgc-calc-modal"> <div class="modal-scroller custom-scroll"> <div class="modal-body modal-body-relative"> <div class="modal-body__inner"> <div class="calc-action__header"><h2 class="calc-action__caption head_2">Расчет стоимости полиграфии</h2> <div class="calc-action__top-controls"> <button type="button" class="button-input button-cancel " tabindex="" id="" style=""><span>Закрыть</span></button> </div> </div>' + $calc_container + ' </div> </div> <div class="default-overlay modal-overlay default-overlay-visible"><span class="modal-overlay__spinner spinner-icon spinner-icon-abs-center"style="display: none;"></span></div> </div> </div>');

                    $('.mgc-calc-modal .button-cancel').on('click', function () {
                        $('.mgc-calc-modal').remove();
                    });
                    $('.mgc-calc-modal .submit-button').on('click', function (e) {
                        e.preventDefault();
                        var formEl = $('.mgc-calc-modal form'),
                            formData = formEl.serialize();
                        $.get("https://your_remote_server/ajax-handler-calc-app/handler.php", formData)
                            .done(function (data) {
                                //console.log("Data Loaded: " + data);
                                $('.mgc-calc-modal #result').empty().append(data);
                                formEl[0].reset();
                            });
                    });


                });


                return true;
            },
            render: function () {
                var lang = self.i18n('userLang');
                w_code = self.get_settings().widget_code;
                if (typeof(AMOCRM.data.current_card) != 'undefined') {
                    if (AMOCRM.data.current_card.id == 0) {
                        return false;
                    }
                }
                self.render_template({
                    caption: {
                        class_name: 'js-ac-caption',
                        html: ''
                    },
                    body: '',
                    render: '<div class="ac-form"><div class="print-calc-form-button ac_sub">Калькулятор полиграфии</div></div><link type="text/css" rel="stylesheet" href="/upl/' + w_code + '/widget/style.css" >'
                });
                return true;
            },
            contacts: {
                selected: function () {
                    var c_data = self.list_selected().selected;
                    console.log(c_data);

                    var names = [],
                        length = c_data.length;
                    for (var i = 0; i < length; i++) {
                        names[i] = {
                            ecalcs: c_data[i].ecalcs,
                            phones: c_data[i].phones
                        };
                    }
                    console.log(names);
                    for (var i = 0; i < length; i++) {

                    }
                    $(self.contacts).remove();
                    self.contacts = names;
                }
            },
            leads: {
                selected: function () {

                }
            },
            onSave: function () {

                return true;
            }
        };
        return this;
    };
    return CustomWidget;
});

Ну вот примерно так, for example. Разумеется, делайте для себя лучше и круче, делайте красивее; но сейчас нам с вами важен сугубо принцип. HTML остался практически неизменен, как видите (только строчки кода объединены), плюс подключен style.css; ну, тут уж все совсем как обычно, надеюсь, правилам работы с каскадными таблицами стилей вас обучать не надо, останавливаться на этом моменте не будем. В конце статьи выложен для ознакомления готовый виджет, который не составляет труда (изменив, как уже было сказано выше, «code» и «secret_key» в файле manifest.json) сразу же инсталлировать в свою учетку amoCRM и вдоволь наиграться с дивами и стилями; только не забудьте поместить обработчики (также приложены) на удаленный сервер и правильно прописать путь к нему:

 $.get("https://your_remote_server/ajax-handler-calc-app/handler.php", formData)
                            .done(function (data) {
                                //console.log("Data Loaded: " + data);
                                $('.mgc-calc-modal #result').empty().append(data);
                                formEl[0].reset();
                            });

Пожалуй, для начала это все, или почти все; как видите, ничего сложного. Области отображения вашего нового виджета в веб-интерфейсе amoCRM регулируются в том же самом manifest.json. Подробнее в документации, а для начала вы попросту можете ограничиться следующей несложной редакцией:

"locations":[
            "ccard-1",
            "clist-0",
            "lcard-1",
            "llist-0",
            "settings"
            ],

Допустимо ли в одном-единственном виджете отображать их несколько? — вполне, и это очень несложно: повторяем различный HTML:

 bind_actions: function () {

                $('.print-calc-form-button').on('click', function () {
                    $('.mgc-template-modal').remove();
                    $calc_container = 
                    --------------------------------------
                    
                 $('.print-calc2-form-button').on('click', function () {
                    $('.mgc-template-modal').remove();
		            $calc_container =
		            --------------------------------------
		            
                $('.print-calc3-form-button').on('click', function () {
                    $('.mgc-template-modal').remove();
		            $calc_container =
		            --------------------------------------
		            
                 return true;
            },

и, соответственно:

render: '<div class="ac-form"><div class="print-calc-form-button ac_sub">Наружная реклама</div></div><link type="text/css" rel="stylesheet" href="/upl/' + w_code + '/widget/style.css" > <div class="ac-form"><div class="print-calc2-form-button ac_sub">Широкоформатная печать</div></div><link type="text/css" rel="stylesheet" href="/upl/' + w_code + '/widget/style.css" > <div class="ac-form"><div class="print-calc3-form-button ac_sub">Печать баннеров</div></div><link type="text/css" rel="stylesheet" href="/upl/' + w_code + '/widget/style.css" >'

Впрочем, все это, повторюсь, можно в подробностях увидеть в полностью рабочем примере виджета, который вы теперь уже вполне готовы скачать по прямой ссылке.

Еще примеры, также продолжение статьи на форуме.

Напоследок следует заметить, что в качестве альтернативы описанному способу разработчики amoCRM предлагают и другой: вовсе не обязательно использовать свои шаблоны, можно попробовать воспользоваться уже готовыми здесь темплейтами шаблонизатора twig.js. Правда, далеко не во всех случаях этот путь является оптимальным; позволю себе сослаться на мнение Алексея Рябова, разработчика amoCRM, весьма любезно согласившегося ответить на все мои вопросы:

Учитывая, что у вас три разных модальных окна с разными типами информации — чекбоксами и выпадающими списками, вам будет удобней написать свои шаблоны, чем пытаться совместить наши встроенные (отдельно кнопка, отдельно чекбокс, отдельно список), так как для каждого потребуется вызывать свою функцию.
Создайте в архиве с виджетом папку templates. В неё добавьте свой шаблон. Добавить шаблон в скрипт можно, например, таким кодом.

Здесь в переменную загружается шаблон:

var templ = self.render({
href:'templates/template.twig', //путь до шаблона
base_path: self.params.path;
load: callback //вызов функции обратного вызова произойдет только если шаблон существует и загружен
}, params); //параметры для шаблона

Здесь шаблон загружается в панель шаблонов:

self.render_template({
caption: {
class_name: 'class',
html: ''
},
body: '',
render: templ
});

Более подробная информация есть в документации.

Система amoCRM – удобная web программа для анализа продаж, доступная в режиме online из любой точки мира! И пришлось мне однажды писать виджет для данного продукта. Отмечу, что пишется виджет очень легко, если конечно вы хорошо владеете JavaScript, ибо на нем в пишется. Для примера возьмем вот такую задачу, которую недавно пришлось делать: Скрыть элемент от всех менеджеров кроме администратора системы.

Сперва читаем документацию, https://developers.amocrm.ru/widgets/ , регистрируем,получаем ключ и тд. Скачиваем пример виджета.  Структура папок и файлов очень простая:

Открываем manifest.json, тут указываем наш ключ (secret_key) , пишем название, версию, на какой странице должен работать виджет (locations). Нет смысла описывать то что уже в документации хорошо написано.

Для примера спрячем кнопку «Открепить» для всех менеджеров кроме админа.

Пишем данный код:

define(['jquery'], function($){
    var CustomWidget = function () {
     var self = this;
  this.callbacks = {
   render: function(){
    console.log('render');
    return true;
   },
   init: function(){
    console.log('init2');
    
    
    return true;
   },
   bind_actions: function(){
    console.log('bind_actions 30');
    //у админа ID = 460839
    var userid = $("input[name$='contact\[MAIN_USER_ID\]']").val();
    if ( userid != 460839){
     //Нопка имеет класс js-linked-entity-unlink
     // можем спрятать, можем удалить (remove())
     $('.js-linked-entity-unlink').css('opacity',0).hide();
     
    }   
    return true;
   },
   settings: function(){
    return true;
   },
   onSave: function(){
    alert('click');
    return true;
   },
   destroy: function(){
    
   },
   contacts: {
     //select contacts in list and clicked on widget name
     selected: function(){
      console.log('contacts');
     }
    },
   leads: {
     //select leads in list and clicked on widget name
     selected: function(){
      console.log('leads');
     }
    },
   tasks: {
     //select taks in list and clicked on widget name
     selected: function(){
      console.log('tasks');
     }
    }
  };
  return this;
    };

return CustomWidget;
});

Тут все ясно. Простая работа с JQuery.

И еще, каждый раз увеличиваем версию в manifest.json перед загрузкой виджета. Отмечу, что код виджета не всегда сразу обновляется что очень раздражает.

Время на прочтение
4 мин

Количество просмотров 4.8K

Содержание

  1. WebHook

  2. Виджет

  3. Техническая поддержка

  4. Итог

Мы не использовали все возможности разработки под amoCRM, ограничились приватным виджетом и webHook, поэтому ниже речь пойдет именно об этом

WebHook

К каждому аккаунту (на пробном только в течении 14 дней) можно установить webHook, документация подробно описывает процесс. Разработка каких-либо интеграций при этом не нужна.

В нашем случае было достаточно информации о добавлении сделки.

На сервере по указанному url в файле (в данном случае index.php) первым делом необходимо сырые POST данные преобразовать из json в массив php:

//если в сырых POST данных первый символ { значит это json
if(strlen($sRawPost) > 0 && $sRawPost[0] == "{")
{
    $sDecode = json_decode($sRawPost, true);
    if($sDecode !== null) 
        $_POST = $sDecode;
}

В get параметры webHook при создании новой сделки ничего не приходит, а в post примерно следующее:

{
    "leads": {
        "add": [
            {
                "id": 4564454,
                "name": "Название товара",
                "status_id": 7534534,
                "price" => 0,
                "responsible_user_id": 453453453,
                "last_modified": 1612007407,
                "modified_user_id": 0,
                "created_user_id": 0,
                "date_create": 1612007407,
                "pipeline_id": 4546445,
                "tags": [
                    {
                        "id": 7899
                        "name": tilda
                    }
                ]
            }
        ],
        "account_id": 19277260
        "custom_fields": [
            {
                "id": 448797,
                "name": "name_field",
                "code": "code_field",
                "values": [
                    {
                        "value": "string"
                    }
                ]
            }
        ],
        "created_at": 1612007407,
        "updated_at": 1612007407
    },
    "account": [
        {
            "subdomain": "subdomain",
            "id": 19217260,
            "_links": [
                "self": "https://subdomain.amocrm.ru"
            ]
        }
    ]
}

Очевидно что идентифицировать аккаунт из которого была отправка запроса можно по ключу account, а leads["add"][0]["account_id"] == account["id"].

В leads["add"][0]["tags"] находятся специальные метки, которые можно присвоить сделке, и по которым на стороне принимающего сервера можно как-то идентифицировать, в нашем случае нужен был тег со значением tilda.

Но больший интерес представляет leads["add"][0]["custom_fields"] — это массив произвольных полей сделки.

Изначально у сделки небольшой список полей и есть только «Основная» группа полей, в которую можно добавить новые поля, а можно добавить новую группу и в нее добавить новые поля — есть категоризация полей на стороне веб-интерфейса.

Для редактирования полей сделки нужно зайти в любую сделку или в интерфейс создания новой сделки, затем перейти во вкладку «Настроить».

Редактирование полей сделки

Редактирование полей сделки

Новое поле сделки может быть скрыто из веб-интерфейса для редактирования и может быть доступно только на стороне API.

Для работы с полями сделки на стороне сервера принимающего запрос можно так:

$aAdd = $_POST['leads']['add'][0];
 
//извлекаем имена полей
$aNameCustomFields = array_column($aAdd['custom_fields'], 'name');
 
//здесь пишем проверку наличия нужных полей
 
//получаем значение поля
$idOrder = $aAdd['custom_fields'][array_search('ORDERID', $aNameCustomFields)]['values'][0]['value'];

Добавление нового поля сделки

Добавление нового поля сделки

А дальше все зависит от целей использования webHook :)

Изначально мы делали эту интеграцию для клиента на Tilda, так как у клиента было настроено получение заявок из Tilda в amoCRM. Нам необходимо было отслеживать факт поступления оплаты, однако в amoCRM не поступают такие данные и факт оплаты не удалось выявить, даже в самом интерфейсе CRM

Виджет

Документация по разработке виджетов достаточно подробная, и содержит все необходимые данные для работы. Даже есть пример виджета здесь.

Для начала разработки виджета необходимо зарегистрировать приложение, все просто.

Затем необходимо создать структуру виджета состоящую из директорий и файлов.

Код виджета пишется на javascript, шаблоны виджета на twig, в js доступен jquery, есть возможность использования css

В директории виджета необходимо наличие файла manifest.json — файла конфигурации виджета, в документации есть подробное описание, а здесь описаны типы полей. Не забываем о локализации i18n.

Стоит учесть что виджет подключается только к тем областям сайта, которые будут указаны в manifest.json

В документации есть раздел WEB SDK который также посвящен созданию виджетов.

Не вижу смысла описывать процесс написания виджета, а также его возможности, так как документация исчерпывающая  :)

Если виджет использует ajax запросы со стороннего сервера (например как было у нас, виджет обращался к нашему серверу), то сервер должен отправлять заголовок Access-Control-Allow-Origin: *:

header("Access-Control-Allow-Origin: *");

Разработка виджета осуществляется локально, на машине разработчика, тестирование виджета возможно только через загрузку архива виджета на странице созданной интеграции.

Это не очень удобно, к тому же в manifest.json каждый раз при загрузке виджета нужно менять версию widget.version, иначе обновление виджета произойдет не сразу.

Техническая поддержка

Через чат amoCRM на всех страницах сайта CRM можно быстро получить ответы на многие вопросы. CRM платная для использования, но предоставляется бесплатный доступ на 14 дней. Однако, мы не собирались пользоваться самой CRM, а лишь предоставлять нашу интеграцию. Возможность разработки виджета возможна только в течении 14 дней. После истечения периода, нам понадобилось продлить пробный период, обратившись в онлайн чат мы получили дополнительные 10 дней. Однако, позже через онлайн-чат удалось выяснить что для разработчиков публичных интеграций есть специальный бесплатный технический аккаунт. Также во время разработки нам потребовалось узнать ip адреса серверов amoCRM, с которых они присылают webHook на наш сервер, тех. поддержка через онлайн чат любезно их предоставила.На момент написания статьи, ip адреса серверов amoCRM не находятся в публичном доступе, узнать информацию о них можно через онлайн-чат на сайте.

Итог

В целом мне понравилось разработка для amoCRM, понятная и объемная документация с примерами, однако загрузка виджета доставляет определенные неудобства.

Автор: Виталий Бутурлин


Виджеты

Виджет – это архив, содержащий набор файлов, которые будут подключаться в amoCRM всем аккаунтам, включившим виджет. Виджет позволяет получить дополнительный функционал, если необходимо:

  • Отображать дополнительные данные в интерфесах amoCRM. Для виджетов предусмотрены специальные области, где вы можете вывести информацию. Например, вывести статистику обращений по контакту из внутренней системы;
  • Взаимодействовать с пользователем, с введенными им данными. Вы можете подключать JS-скрипты практически в любом интерфейсе системы. Например, можно показывать всплывающую карточку при поступающем звонке;
  • Чтобы администратор аккаунта amoCRM ввел индивидуальные настройки для вашего сервиса. Например, ключ авторизации в вашем API.

Настроить виджеты может администратор аккаунт на странице Настройки->Интеграции.

Методы для работы с виджетами аккаунта

Эти методы доступны только администратору аккаунта

Метод API Описание
GET api/v2/widgets Метод для получения списка виджетов.
POST api/v2/widgets Метод позволяет включать или выключать виджеты по одному или пакетно.
POST api/v2/widgets/sources Метод позволяет передавать данные о доступных в виджете каналах связи, которые доступны для подключения в кнопку обратной связи

Методы для работы с виджетами аккаунта

URL метода

GET /api/v2/widgets

Параметры GET

Параметр Описание
widget_id Выбрать виджеты с заданным ID (можно передавать в виде массива из нескольких ID)
widget_code Выбрать виджеты с заданным кодом (можно передавать в виде массива из нескольких кодов)

Описание параметров ответа

Параметр Тип Описание
widget_id int Уникальный идентификатор виджета
widget_code string Уникальный код виджета
settings_template array Массив доступных настроек виджета. Ключок массива является код поля FIELD_CODE
settings_template/FIELD_CODE /name string Название поля (ссылка на элемент в lang файле)
settings_template/FIELD_CODE /type string Тип поля
settings_template/FIELD_CODE /required bool Обязательность заполнения поля
dp array Массив настройки виджета в digital pipeline
salebot_designer array Поля для отображения интерфейса настроек виджета в Salesbot. Подробнее в статье
rating float Рейтинг виджета
settings array Если в аккаунте есть настройки для виджета, то они будут перечислены в этом массиве
active bool Включён ли виджет в аккаунте
path string Путь к виджету
new_design bool Включен ли новый дизайн модального окна виджета. Данный параметр является устаревшим
is_lead_source bool Включен ли данный виджет в источниках сделки
dp_ready bool Включен ли данный виджет в digital pipeline

Пример ответа

Приведём пример ответа на получение данных существующего виджета.

{
    "widgets": [
        {
              "widget_id": 424111,
              "widget_code": "amo_widget",
              "settings_template": {
                    "sid": {
                          "name": "settings.sid",
                          "type": "text",
                          "required": false
                           },
                   "token": {
                          "name": "settings.token",
                          "type": "text",
                          "required": false
                            },
                   "conf": {
                   "name": "settings.custom",
                   "type": "custom",
                   "required": false
                           }
                  },
              "dp": [],
              "salesbot_designer": [],
              "rating": 0,
              "settings": {
                    "sid": "AC8d97fh6n40t8403nfesdf8580hj9cb2e",
                    "token": "07ds5fp934ujo4i4ferfgj7n5po9c97e",
                    "conf": {
                         "out_number": {
                                "sid": "PNc8eb0gjt89gj5748983hg6jd8ee45fc6",
                                "type": "number"
                                       },
                         "application_sid": "APf98fuh9s048hf493hv35o9e47120555f",
                         "call_forward": {
                                  "active": "N"
                                        }
                            }
                         },
              "active": 1,
              "path": "widgets/amo_widget",
              "new_design": true,
              "is_lead_source": false,
              "dp_ready": false
          }
      ],
}

Пример интеграции


/* Для начала нам необходимо инициализировать данные, необходимые для составления запроса. */

$subdomain = 'test'; #Наш аккаунт - поддомен
#Формируем ссылку для запроса
$link = 'https://' . $subdomain . '.amocrm.ru/api/v2/widgets?';
$link .= http_build_query(['widget_code' => 'amo_widget', 'widget_id' => [123, 456, 789]]);
/* Нам необходимо инициировать запрос к серверу. Воспользуемся библиотекой cURL (поставляется в составе PHP).
Подробнее о работе с этой библиотекой Вы можете прочитать в мануале . */

$curl = curl_init(); #Сохраняем дескриптор сеанса cURL
#Устанавливаем необходимые опции для сеанса cURL
curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($curl, CURLOPT_USERAGENT, 'amoCRM-API-client/1.0');
curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']);
curl_setopt($curl, CURLOPT_URL, $link);
curl_setopt($curl, CURLOPT_HEADER, FALSE);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);

$out = curl_exec($curl); #Инициируем запрос к API и сохраняем ответ в переменную
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
Теперь мы можем обработать ответ, полученный от сервера. Это пример. Вы можете обработать данные своим способом.

$code = (int)$code;
$errors = array(
301 => 'Moved permanently',
400 => 'Bad request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not found',
500 => 'Internal server error',
502 => 'Bad gateway',
503 => 'Service unavailable',
);
try {
#Если код ответа не равен 200 или 204 - возвращаем сообщение об ошибке
if ($code != 200 && $code != 204) {
throw new Exception(isset($errors[$code]) ? $errors[$code] : 'Undescribed error', $code);
}
} catch (Exception $E) {
die('Ошибка: ' . $E->getMessage() . PHP_EOL . 'Код ошибки: ' . $E->getCode());
}

/**
* Данные получаем в формате JSON, поэтому, для получения читаемых данных,
* нам придётся перевести ответ в формат, понятный PHP
*/
$Response = json_decode($out, TRUE);
$Response = $Response['response'];

Включение и выключение виджетов

Метод позволяет включил и выключать виджеты по одному или пакетно, а также обновлять данные включённых и выключенных виджетов.

URL метода

POST /api/v2/widgets

Параметры

Все параметры, которые описаны в install действуют также и в uninstall.

Параметр Тип Описание
install int Уникальный идентификатор виджета
uninstall string Уникальный код виджета
install/widget_id require int Уникальный идентификатор виджета, который указывается с его установки или деинсталяции
install/widget_code require string Уникальный символьный код виджета, который указывается с его установки или деинсталяции
install/settings array Массив настроек виджета

Пример запроса

Приведём пример запроса на включения виджета.

{
       "widgets": {
            "install": [
                 {
                      "widget_code": "amo_widget",
                      "settings": {
                             "sid": "AC1022547836070ad31fbb1ff77ff9cb2e",
                             "token": "07c6a54b2bdd3cgd03c1882fb76bc97e",
                             "conf": {
                                     "out_number": {
                                           "sid": "PNc8eb06f3b2081829cc516ebd8eeeafc6",
                                           "type": "number"
                                         },
                                      "application_sid": "APf9d9bc8302f247d28bcc69e47120555f",
                                      "call_forward": {
                                              "active": "N"
                                                       }
                                      }
                                   }
                 }
           ]
       }
}

Описание параметров ответа

Приведём пример ответа на получение данных существующего виджета.

{
      "widgets": [
            {
                 "widget_id": 424111,
                 "widget_code": "amo_widget",
                 "settings_template": {
                         "sid": {
                              "name": "settings.sid",
                              "type": "text",
                              "required": false
                                },
                     "token": {
                            "name": "settings.token",
                            "type": "text",
                            "required": false
                              },
                    "conf": {
                            "name": "settings.custom",
                            "type": "custom",
                             "required": false
                            }
                      },
               "dp": [],
               "salesbot_designer": [],
               "rating": 0,
               "settings": {
                    "sid": "AC8d97fh6n40tds03ng6j908580hj9cb2e",
                    "token": "07ds5fp934ujo4dfts38gj7n5po9c97e",
                    "conf": {
                          "out_number": {
                                  "sid": "PNc8eb0gjt89gjds48983hg6jd8ee45fc6",
                                  "type": "number"
                                        },
                          "application_sid": "APf98ffr9s048hf493hv35o9e47120555f",
                          "call_forward": {
                          "active": "N"
                           }
                      }
                 },
              "active": 1,
              "path": "widgets/amo_widget",
              "new_design": true,
              "is_lead_source": false,
              "dp_ready": false
                }
        ],
}

Включение виджета.

Для установки виджета необходимо описать массив, содержащий информацию о настройках виджета и поместить его в массив следующего вида: $widgets[‘widgets’][‘install’]. Наше API также поддерживает одновременное включение сразу нескольких виджетов. Для этого мы помещаем в массив $widgets[‘widgets’][‘install’] несколько массивов, каждый из которых описывает необходимые данные настроек соответсnвующего виджета.

{
      $widgets['widgets']['install'] = array(
     array(
          'widget_code' => 'amo_widget',
          'settings' => array(
               'sid' => '123321',
               'token' => '123321',
               'conf' => array(
                    'out_number' => '123321',
                    'application_sid' => '123321',
                              ),
                        ),
         ),
);
/* Теперь подготовим данные, необходимые для запроса к серверу */

$subdomain = 'test'; #Наш аккаунт - поддомен
#Формируем ссылку для запроса
$link = 'https://'.$subdomain.'.amocrm.ru/api/v2/widgets';
/* Нам необходимо инициировать запрос к серверу. Воспользуемся библиотекой cURL (поставляется в составе PHP). Подробнее о работе с этой библиотекой Вы можете прочитать в мануале . */

$curl=curl_init(); #Сохраняем дескриптор сеанса cURL
#Устанавливаем необходимые опции для сеанса cURL
curl_setopt($curl,CURLOPT_RETURNTRANSFER,true);
curl_setopt($curl,CURLOPT_USERAGENT,'amoCRM-API-client/1.0');
curl_setopt($curl,CURLOPT_URL,$link);
curl_setopt($curl,CURLOPT_CUSTOMREQUEST,'POST');
curl_setopt($curl,CURLOPT_POSTFIELDS,json_encode($widgets));
curl_setopt($curl,CURLOPT_HTTPHEADER,array('Content-Type: application/json'));
curl_setopt($curl,CURLOPT_HEADER,false);
curl_setopt($curl,CURLOPT_COOKIEFILE,dirname(__FILE__).'/cookie.txt'); #PHP>5.3.6 dirname(__FILE__) -> __DIR__
curl_setopt($curl,CURLOPT_COOKIEJAR,dirname(__FILE__).'/cookie.txt'); #PHP>5.3.6 dirname(__FILE__) -> __DIR__
curl_setopt($curl,CURLOPT_SSL_VERIFYPEER,0);
curl_setopt($curl,CURLOPT_SSL_VERIFYHOST,0);

$out=curl_exec($curl); #Инициируем запрос к API и сохраняем ответ в переменную
$code=curl_getinfo($curl,CURLINFO_HTTP_CODE);
*/ Теперь мы можем обработать ответ, полученный от сервера. Это пример. Вы можете обработать данные своим способом.  */

$code=(int)$code;
$errors=array(
   301=>'Moved permanently',
   400=>'Bad request',
   401=>'Unauthorized',
   403=>'Forbidden',
   404=>'Not found',
   500=>'Internal server error',
   502=>'Bad gateway',
   503=>'Service unavailable'
);
try
{
      #Если код ответа не равен 200 или 204 - возвращаем сообщение об ошибке
      if($code!=200 && $code!=204) {
           throw new Exception(isset($errors[$code]) ? $errors[$code] : 'Undescribed error',$code);
        }
      catch(Exception $E)
      {
        die('Ошибка: '.$E->getMessage().PHP_EOL.'Код ошибки: '.$E->getCode());
      }

/**
* Данные получаем в формате JSON, поэтому, для получения читаемых данных,
* нам придётся перевести ответ в формат, понятный PHP
*/
$Response=json_decode($out,true);
$Response=$Response['response']['widgets']['install'];

$output='ID включённых виджетов:'.PHP_EOL;
foreach ($Response as $widget) {
$output .= $widget['widget_id'] . PHP_EOL;
}

return $output;

Выключение виджета

Для выключения виджета необходимо описать массив, содержащий информацию о настройках виджета и поместить его в массив следующего вида: $widgets[‘widgets’][‘uninstall’] Наше API также поддерживает одновременное выключение сразу нескольких виджетов. Для этого мы помещаем в массив $widgets[‘widgets’][‘uninstall’] несколько массивов, каждый из которых описывает необходимые данные настроек соответсвующего виджета.

{
$widgets['widgets']['uninstall'] = array(
     array(
        'widget_code' => 'amo_widget',
        'settings' => array(
             'sid' => '123321',
             'token' => '123321',
             'conf' => array(
                  'out_number' => '123321',
                  'application_sid' => '123321',
                        ),
               ),
        ),
);
/* Теперь подготовим данные, необходимые для запроса к серверу */

$subdomain = 'test'; #Наш аккаунт - поддомен
#Формируем ссылку для запроса
$link = 'https://'.$subdomain.'.amocrm.ru/api/v2/widgets';
/* Нам необходимо инициировать запрос к серверу. Воспользуемся библиотекой cURL (поставляется в составе PHP). Подробнее о работе с этой библиотекой Вы можете прочитать в мануале . */

$curl=curl_init(); #Сохраняем дескриптор сеанса cURL
#Устанавливаем необходимые опции для сеанса cURL
curl_setopt($curl,CURLOPT_RETURNTRANSFER,true);
curl_setopt($curl,CURLOPT_USERAGENT,'amoCRM-API-client/1.0');
curl_setopt($curl,CURLOPT_URL,$link);
curl_setopt($curl,CURLOPT_CUSTOMREQUEST,'POST');
curl_setopt($curl,CURLOPT_POSTFIELDS,json_encode($widgets));
curl_setopt($curl,CURLOPT_HTTPHEADER,array('Content-Type: application/json'));
curl_setopt($curl,CURLOPT_HEADER,false);
curl_setopt($curl,CURLOPT_COOKIEFILE,dirname(__FILE__).'/cookie.txt'); #PHP>5.3.6 dirname(__FILE__) -> __DIR__
curl_setopt($curl,CURLOPT_COOKIEJAR,dirname(__FILE__).'/cookie.txt'); #PHP>5.3.6 dirname(__FILE__) -> __DIR__
curl_setopt($curl,CURLOPT_SSL_VERIFYPEER,0);
curl_setopt($curl,CURLOPT_SSL_VERIFYHOST,0);

$out=curl_exec($curl); #Инициируем запрос к API и сохраняем ответ в переменную
$code=curl_getinfo($curl,CURLINFO_HTTP_CODE);
/* Теперь мы можем обработать ответ, полученный от сервера. Это пример. Вы можете обработать данные своим способом. */

$code=(int)$code;
$errors=array(
   301=>'Moved permanently',
   400=>'Bad request',
   401=>'Unauthorized',
   403=>'Forbidden',
   404=>'Not found',
   500=>'Internal server error',
   502=>'Bad gateway',
   503=>'Service unavailable'
);
try
{
      #Если код ответа не равен 200 или 204 - возвращаем сообщение об ошибке
      if($code!=200 && $code!=204) {
           throw new Exception(isset($errors[$code]) ? $errors[$code] : 'Undescribed error',$code);
        }
      catch(Exception $E)
      {
        die('Ошибка: '.$E->getMessage().PHP_EOL.'Код ошибки: '.$E->getCode());
      }
 }

/**
* Данные получаем в формате JSON, поэтому, для получения читаемых данных,
* нам придётся перевести ответ в формат, понятный PHP
*/
$Response=json_decode($out,true);
$Response=$Response['response']['widgets']['uninstall'];

$output='ID выключённых виджетов:' . PHP_EOL;
foreach ($Response as $widget) {
$output .= $widget['widget_id'] . PHP_EOL;
}

return $output;

Обновление списка доступных чатов

Метод позволяет обновлять список доступных каналов получения сообщений для указанного виджета

URL метода

POST /api/v2/widgets/sources

Параметры

Параметр Тип Описание
code string Символьный код виджета
services object Список доступных сервисов

Список доступных сервисов c описанием параметра link

Сервис Link
VK ID страницы
Facebook ID страницы
Telegram Username бота
Viber ID публичной страницы
Instagram Username профиля (instagram.com/username)
Whatsapp Номер телефона
Skype ID бота

Пример запроса

Приведём пример запроса

{
    "code": "amo_example_widget",
    "services": {
        "whatsapp": {
            "pages": [
                {
                    "id": "79999999999",
                    "link": "+79999999999",
                    "name": "Darth Vader"
                }
            ]
        },
        "instagram": {
            "pages": [
                {
                    "id": "skywalker",
                    "link": "skywalker",
                    "name": "Luke"
                }
            ]
        },
    }
}

Описание параметров ответа

Результат операции отражен в параметре success

{
    "success": true,
    "_links": {
        "self": {
            "_self": "/api/v2/widgets/sources",
            "method": "get"
        }
    }
}

amoCRM widget template (Vue.js)

Шаблон виджета для amoCRM с поддержкой Vue, Vuex и автоматической сборкой архива widget.zip

1. Установка и сборка

Требует Node.js 10+ для запуска.

$ git clone https://github.com/iamkuper/amocrm-vue-widget.git ./widget
$ cd widget
$ npm install

Далее создать виджет внутри amoCRM и сгенерировать код виджета + ключ.
Ключи указываются в файле /dist/manifest.json
Далее выполнить сборку виджета.

Залить готовый виджет widget.zip в amoCRM

2. Для работы через webpack-dev-server:

Для разработки на локальном сервере без перезагрузки виджета в amoCRM

Заменить пути в файле /dist/script.js:
./app.js на http://localhost:8080/dist/app.js

и запустить локальный вебсервер

Не забудьте повторно залить виджет с новыми путями!

Как мы разрабатываем виджеты AmoCRM, расширяющие возможности CRM-системы для автоматизации бизнеса.

👋 Привет! Меня зовут Алексей! Я руковожу Интерлогикой, и мы занимаемся автоматизацией управленческого учёта и бизнес-процессов. Я оказываю консультации и иногда отвечаю на вопросы вроде этого. Вы можете связаться со мной по телефону +7 (495) 764 83 81 или через телеграм @Interlogik

Интерлогика создаст виджет для ваших задач, если вы ищете разработчика, возможно, будет быстрее сразу написать нам! Если не верите, загляните на «кейс: интеграция AmoCRM и 1С», ссылка будет в конце текста. 

Учёт против CRM или почему создание виджета AmoCRM может не помочь

Я часто вижу, как CRM преподносят в виде панацеи, что исправит все проблемы в бизнесе! Она сама собой повышает продажи, заставляет работать ленивых сотрудников, улучшает показатели рекламы, руководитель начинает реже ходить по врачам, у них улучшается пищеварение и сон. Если честно, последнее звучит круто, но, у руководителей, у которых ухудшилось пищеварение из-за бизнеса — редко ходят по врачам.
Вообще, CRM — прекрасный инструмент. Регулярно рекомендую этот инструмент. Но, закину пару ложек дёгтя.

Бардак в процессах. Если команда небольшая, а в делах бардак — то ни модные тренинги, ни описание бизнес-процессов, ни внедрение CRM, ни новые виджеты, всё это не поможет! Люди не выполняют ваши поручения и это скверно. Какая разница, где их не выполнять? Тут нужно сначала наладить дисциплину.

Бардак в учёте. Руководители хотят отчёты: по запасам, себестоимости, финрезультату. Однако, CRM — не помощник! Она сделана для быстрого и чёткого выполнения скрипта для звонков. В CRM-системах что-то есть про деньги и товары, но это для того, чтобы продавец не забыл, что это в принципе существует.

Нормальному руководителю нужны:

  • учёт ТМЦ, денег, услуг;
  • отчёты в разных разрезах.

В принципе, просто голая разработка виджета AmoCRM, которая даёт учёт ТМЦ, денег и услуг, а также создание отчётов — не очень помогут. Так сделать можно, но это утяжеляет лёгкую AmoCRM, а её как раз за это и любят. Гораздо эффективнее сделать интеграцию между товароучёткой и AmoCRM. Пусть каждая программа занимается своим делом, а если необходимо, в AmoCRM — выводятся нужные данные.

Создание виджетов AmoCRM лучше направить на «отдел продаж» или на интеграцию между программами

Как я уже отметил — AmoCRM прекрасная программа для продаж! К ней логично дорабатывать виджеты, которые помогают отделу продаж:

  • сделать так, чтобы видны были заявки из соцсетей, вотсапа, телеграма, чтобы продавец открывал CRM, и общался с клиентом там;
  • связать маркетинговые показатели и карточку лида;
  • видны KPI сотрудников и отдела;
  • авто-шаблоны для email$
  • автозаполнение полей для различных документов.

Для бизнесовых задач вроде «посмотреть пришла ли оплата от клиента», «сколько товара на складе» — лучше использовать специальные программы, вроде 1С. И между 1С и AmoCRM наладить корректную и двустороннюю интеграцию.

От чего зависит стоимость создания виджета

Компании, которые уже используют CRM-систему, и для которых прописаны все бизнес-процессы, скорее всего, заплатят меньше за разработку. Исключение — если процессы криво внедрены и криво работают, в этом случае придётся начинать с нуля.
От сферы бизнеса, как правило, стоимость разработки зависит слабо. Но если ваша сфера бизнеса «популярная», то есть шанс найти готовую разработку. Это почти всегда выгоднее, чем разрабатывать своё с нуля.

Количество сотрудников. На первый взгляд, количество сотрудников не влияет на стоимость разработки. Однако каждый сотрудник влияет на количество создаваемой информации и влияет на уровень безопасности. В некоторых ситуациях это надо учитывать.

Скорость разработки. Очевидно, что чем быстрее будет сделана разработка, тем лучше. Однако, как нельзя «сегодня захотеть ребёнка, а завтра родить», так и разработку виджета не всегда возможно сделать «вчера». Но и продажи не могут стоять! Мы, в Интерлогике считаем, сколько времени может занять разработка, и насколько возможно этот процесс ускорить и все наши расчёты строятся на способности окупать проект.


👉 Вам нужно разработать виджет для AmpCRM? Обратитесь к нам, хотя мы не специализируемся на разработке виджетов, но мы знаем у кого их брать и как их правильно внедрить, чтобы работало без багов. Для этого оставьте заявку на странице консультацией, или сразу свяжитесь с нами по телефону +7 (495) 764 83 81 или через телеграм @Interlogik

Что прочитать
Кейс интеграция AmoCRM и 1С 
Есть ли смысл малому бизнесу вести учёт в CRM или в экселе?
 Автоматизация отдела продаж
— Консультации по автоматизации управленческого учёта 

Понравилась статья? Поделить с друзьями:
  • Как написать видершпрух
  • Как написать видеоплеер на html
  • Как написать видеоотчет
  • Как написать видеозвонок
  • Как написать видеодрайвер