Мой опыт прикручивания JS-компоненты к движку на Yii с описание, кодом и примерами под катом.
Прежде всего скажу, что учитывая специфику разрабатываемой системы документооборота первой мыслью была разработка компоненты «с нуля» своими силами. Но, сведя все требования в длинный список, прикинув объем работ, количество кода на PHP и на JavaScript, немного остыл. И, как и многие другие понял, что разумнее поискать готовые компоненты с необходимым функционалом, даже если они окажутся платными.
Требования к компоненте были следующие:
- Отображение диаграммы Гантта в стиле близком easla.com
- Обратная связь посредством изменения задач мышкой
- Множество event'ов
- Скорость работы.
Учитывая, что диаграмма Гантта весьма популярный метод отображения информации, компонент в Интернете оказалось очень много. Просматривал и отбирал их наверное целый день. Конечно, сперва, отказывался от самих простых, представляющих исключительно базовый функционал, и постепенно формировал короткий список наиболее мощных и продвинутых компонент.
Внимательно изучил только парочку компонент, одной из которых и была DHTMLX Gantt. На ней и остановился.
Задача
Требования к интеграции компоненты были такие:
- Интеграция в easla.com в виде компоненты
- Отображение данных постранично/целиком
- Фильтр и сортировка средствами easla.com
- Отображение шапки диаграммы также, как в других видах (таблицах)
- Обратная связь при изменении задач в диаграмме.
Первый же пункт вызвал ряд вопросов. Компонента на 100% клиентская, т.е. вся написана на JavaScript, а нужно, чтобы она инициализировалась с помощью PHP и принимала множество входных параметров. К счастью, DHTMLX Gantt написана очень качественно и с помощью входных параметров ее можно настроить ровно так, как надо.
Постраничное отображение – следующая головная боль. На форуме разработчика звучали вопросы о «постраничности», но в ответ только недоумение типа: «Зачем это нужно? Это же нарушает идеологию диаграммы Гантта!» Однако в моем случае без «постраничности» никак, поэтому схема реализации тоже была найдена еще до реализации.
Фильтр и сортировка такой же непростой вопрос как и постраничное отображение. Сортировка в компоненте есть своя, но она может быть использована только при отображении всех данных в таблице сразу. Иначе говоря, при постраничном отображении встроенная сортировка работать не будет. Фильтр работает аналогично. Мне пришлось потратить пару дней на изучение реализации render'а в компоненте, чтобы понять, получится ли отрисовывать шапку по-своему. Примерно так:
К счастью, с обратной связью особых проблем не увидел. В компоненте полно event'ов, а также присутствует dataProcessor, посредством которого можно обновлять данные. Кстати, с компонентой предлагают целую толпу классов для интеграции, но мне они не пригодились.
Реализация на PHP
Прежде всего, встал вопрос, какой класс унаследовать, чтобы создать свою компоненту. Очень хотелось унаследовать класс CGridView, но после пары попыток стало понятно, что она избыточна. Одновременно стало очевидно, что наследуемый класс должен обладать базовым набором методов для render'а и «пагинации». В конечном счете остановился на CBaseListView.
Создаваемый класс AlxdDhtmlxGantt очень хотелось сделать похожим на CGridView, чтобы не «изобретать велосипед» и не создавать себе трудностей, поэтому в новый класс начал копировать все нужные свойства прямо из CGridView вместе с комментариями. На текущий момент, в AlxdDhtmlxGantt из уникальных свойств добавлены следующие:
public $onTaskSelected;
public $onTaskOpened;
public $onTaskClosed;
public $onTaskDragStart;
public $onTaskDrag;
public $onBeforeTaskDrag;
public $onBeforeTaskChanged;
public $onAfterTaskDrag;
public $itemsTag = 'div';
public $dataProcessorUrl;
public $itemsStyle='height:500px;';
public $taskAttributes = array();
public $scales;
public $tree = false;
Как очевидно из названий, все on* — это обработчики событий, используемые, преимущественно, для дополнительного render'а checkbox'ов.
taskAttributes – это одно из важных свойств, которое должно содержать список атрибутов отображаемой модели, которые будут использованы диаграммой для наименования, даты начала и окончания задачи. Формат следующий:
public $taskAttributes = array(
'text'=>'description',
'start_date'=>'plan_start_date',
'end_date'=>'plan_end_date'
);
Вместо end_date можно указать duration. Главное, что должен быть указан или атрибут окончания задачи, или ее продолжительности. Подробнее в документации.
scales – это еще одно важное свойство, которое должно описывать временную шкалу диаграммы Гантта. На мой взгляд, разработчики компоненты немного намудрили с настройками временной шкалы, выделив параметры основной шкалы в scale_unit и date_scale, а параметры доп. шкалы в subscales. Но, надеюсь, им там было виднее. Я объединил настройку шкалы в одно свойство класса, которое должно принимать массив всех временных шкал. Надо одну шкалу – значит в массиве будет только одна шкала. Надо две – значит две и т.д. Формат следующий:
Public $scales = array(
array('unit'=>'year', 'step'=>1, 'date'=>'%Y')
array('unit'=>'month', 'step'=>1, 'date'=>'%F, %Y')
);
По-моему так проще.
По аналогии с CGridView в AlxdDhtmlxGantt нужно инициализировать колонки. Их инициализация один в один как в CGridView. Не скрою, метод просто скопирован и немного поправлен.
protected function initColumns()
{
if($this->columns===array())
{
if($this->dataProvider instanceof CActiveDataProvider)
$this->columns=$this->dataProvider->model->attributeNames();
elseif($this->dataProvider instanceof IDataProvider)
{
// use the keys of the first row of data as the default columns
$data=$this->dataProvider->getData();
if(isset($data[0]) && is_array($data[0]))
$this->columns=array_keys($data[0]);
}
}
$id=$this->getId();
foreach($this->columns as $i=>$column)
{
if(is_string($column))
$column=$this->createDataColumn($column);
else
{
if(!isset($column['class'])) {
$column['class'] = 'CDataColumn';
}
$column=Yii::createComponent($column, $this);
}
if(!$column->visible)
{
unset($this->columns[$i]);
continue;
}
if($column->id===null)
$column->id=$id.'_c'.$i;
$this->columns[$i]=$column;
}
$tree_initiated = false;
foreach($this->columns as $column) {
$column->init();
if ($column instanceof CDataColumn && $this->tree && !$tree_initiated) {
$this->tree_column_name = $column->name;
$tree_initiated = true;
}
}
}
Определившись с входными параметрами, пришло время решить вопрос с отображением данных. Взвесив все «за» и «против» пришел к выводу, что первоначальное заполнение диаграммы данными удобнее делать с помощью js, а обратную связь обеспечить через ajax. Перекрыл метод renderItems. Он теперь почти ничего не render'ит:
public function renderItems()
{
if($this->dataProvider->getItemCount()>0 || $this->showTableOnEmpty)
{
echo CHtml::openTag($this->itemsTag, array('class'=>$this->itemsCssClass, 'style'=>$this->itemsStyle));
//render container only
//content render in javascript
echo CHtml::closeTag($this->itemsTag);
}
else
$this->renderEmptyText();
}
Формирование массива значений для компоненты осуществляется с помощью метода getData, который перебирает данные (все или для активной страницы) и передает их в виде массива.
public function getData()
{
$ret = array('data'=>array());
$data = $this->dataProvider->getData();
$n = count($data);
if($n > 0) {
for($row=0; $row < $n; ++$row)
$ret['data'][] = $this->getDataRow($row);
}
return $ret;
}
Печальным оказался тот факт, что public метод renderDataCell отрисовывает значение вместе с тэгом td. Пришлось использовать protected метод renderDataCellContent, вызывая его с помощью ReflectionMethod. Примерно так:
$r = new ReflectionMethod($column, 'getDataCellContent');
$r->setAccessible(true);
$value = $r->invoke($column, $row, $data);
$ret[$column->name] = $value;
Полная инициализации компоненты для отображения осуществляется в методе registerClientScript. В нем же осуществляется загрузка всех необходимых скриптов и стилей отображения, включая скрипт локализации.
public function registerClientScript()
{
$id = $this->getId();
if($this->ajaxUpdate===false)
$ajaxUpdate=false;
else
$ajaxUpdate=array_unique(preg_split('/\s*,\s*/',$this->ajaxUpdate.','.$id,-1,PREG_SPLIT_NO_EMPTY));
$itemsSelector = $this->itemsTag;
$itemsCssClass = explode(' ',$this->itemsCssClass,2);
if (is_array($itemsCssClass)) {
$itemsSelector .= '.'.$itemsCssClass[0];
}
$options=array(
'ajaxUpdate'=>$ajaxUpdate,
'ajaxVar'=>$this->ajaxVar,
'pagerClass'=>$this->pagerCssClass,
'loadingClass'=>$this->loadingCssClass,
'filterClass'=>$this->filterCssClass,
// 'tableClass'=>$this->itemsCssClass,
// 'selectableRows'=>$this->selectableRows,
'enableHistory'=>$this->enableHistory,
'updateSelector'=>$this->updateSelector,
'filterSelector'=>$this->filterSelector,
'itemsSelector'=>$itemsSelector,
);
if($this->ajaxUrl!==null)
$options['url']=CHtml::normalizeUrl($this->ajaxUrl);
if($this->ajaxType!==null)
$options['ajaxType']=strtoupper($this->ajaxType);
if($this->enablePagination)
$options['pageVar']=$this->dataProvider->getPagination()->pageVar;
foreach(array('beforeAjaxUpdate',
'afterAjaxUpdate',
'ajaxUpdateError',
'onTaskSelected',
'onTaskOpened',
'onTaskClosed',
'onTaskDragStart',
'onTaskDrag',
'onBeforeTaskDrag',
'onBeforeTaskChanged',
'onAfterTaskDrag',
/*, 'selectionChanged'*/) as $event)
{
if($this->$event!==null)
{
if($this->$event instanceof CJavaScriptExpression)
$options[$event]=$this->$event;
else
$options[$event]=new CJavaScriptExpression($this->$event);
}
}
$options['config'] = array(
//The default date format for JSON and XML data is "%d-%m-%Y" http://docs.dhtmlx.com/gantt/desktop__loading.html#loadingfromadatabase
'xml_date'=>'%Y-%m-%d',
'columns'=>array_map(function($column){
if ($column instanceof CCheckBoxColumn) {
$ret = array('name'=>$column->name);
} elseif ($column instanceof AlxdStatusrefColumn) {
$ret = array('name'=>$column->name.($column->format ? '.'.$column->format : ''));
} elseif ($column instanceof AlxdAttributerefColumn) {
$ret = array('name'=>$column->name.($column->attribute ? '.'.$column->attribute : ''));
} else {
$ret = array('name'=>$column->name);
}
$r = new ReflectionMethod($column, 'renderHeaderCellContent');
$r->setAccessible(true);
ob_start();
$r->invoke($column);
$ret['label'] = ob_get_contents();
ob_end_clean();
if ($column instanceof CCheckBoxColumn) {
$ret['width'] = 36;
} else {
$headerHtmlOptions = $column->headerHtmlOptions;
if (isset($headerHtmlOptions['style'])) {
$styles = explode(';', rtrim($headerHtmlOptions['style'], ';'));
foreach ($styles as $style) {
$pair = explode(':', $style, 2);
if (count($pair) == 2 && strtolower(trim($pair[0])) == 'width') {
$l = strlen($pair[1]);
if (strtolower(substr($pair[1], $l-2, 2)) == 'px') {
$ret['width'] = substr($pair[1], 0, $l - 2);
}
}
}
}
if ($this->tree && $column->name == $this->tree_column_name) {
$ret['tree'] = $this->tree;
}
}
return $ret;
}, $this->columns),
'filters'=>array_map(function($column){
$r = new ReflectionMethod($column, 'renderFilterCellContent');
$r->setAccessible(true);
ob_start();
$r->invoke($column);
$filter = ob_get_contents();
ob_end_clean();
return array(
'name'=>$column->name,
'control'=>$filter
);
}, $this->columns),
'data'=>$this->getData(),
);
$options['config']['scale_unit'] = $this->scales[0]['unit'];
$options['config']['date_scale'] = $this->scales[0]['date'];
if (count($this->scales) > 1) {
$options['config']['subscales'] = array_slice($this->scales, 1);
}
if ($this->filter !== null) {
$options['config']['scale_height_auto'] = true;
$options['config']['filter'] = true;
}
if (isset($this->dataProcessorUrl)) {
$options['dataProcessorUrl'] = $this->dataProcessorUrl;
}
$options=CJavaScript::encode($options);
$cs=Yii::app()->getClientScript();
if ($this->_assets == null) {
$path = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'assets';
$this->_assets = Yii::app()->assetManager->publish($path);
}
$cs->registerCoreScript('jquery');
$cs->registerCoreScript('bbq');
if($this->enableHistory)
$cs->registerCoreScript('history');
$cs->registerCssFile($this->_assets . DIRECTORY_SEPARATOR . 'dhtmlxgantt.css');
$cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'dhtmlxgantt.js', CClientScript::POS_BEGIN);
$cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'locale/locale_'.Yii::app()->language.'.js', CClientScript::POS_BEGIN);
$cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'alxd.dhtmlxgantt.js', CClientScript::POS_BEGIN);
$cs->registerScript(__CLASS__.'#'.$id,"jQuery('#$id').alxdDhtmlxGantt($options);", CClientScript::POS_READY);
}
Реализация на JavaScript
Мои первые попытки написать компоненту не создавая отдельного js модуля не увенчались успехом, что и к лучшему. Помучившись стало понятно, что надо написать полноценный js-модуль, который будет обрабатывать процесс инициализации DHTML Gantt, привязки событий и обработку переключения страниц и фильтра. Более того, как выяснилось позже, пришлось перекрыть пару методов для корректного render'а шапки и данных диаграммы. Выглядеть должно было как-то так (на картинке монтаж, чтобы показать и фильтр, и контекстное меню):
Порывшись в исходном коде компоненты, нашел два метода: _render_grid_header и _render_grid_item. Попытался хирургически их перекрыть, но ничего не получилось, и в конечном счете полностью их перекрыл скопировав исходный код и внеся в него необходимые правки.
gantt._render_grid_header = function () {
var columns = this.getGridColumns();
var filters = this.config.filters;
var title_cells = [];
var filter_cells = [];
var width = 0,
labels = this.locale.labels;
var lineHeigth = this.config.scale_height - 2;
for (var i = 0; i < columns.length; i++) {
var last = i == columns.length - 1;
var col = columns[i];
var colWidth = col.width*1;
if (last && this._get_grid_width() > width + colWidth)
col.width = colWidth = this._get_grid_width() - width;
width += colWidth;
var sort = (this._sort && col.name == this._sort.name) ? ("<div class='gantt_sort gantt_" + this._sort.direction + "'></div>") : "";
var cssClass = ["gantt_grid_head_cell",
("gantt_grid_head_" + col.name),
(last ? "gantt_last_cell" : ""),
this.templates.grid_header_class(col.name, col)].join(" ");
var style = "width:" + (colWidth - (last ? 1 : 0)) + "px;";
var label = (col.label || labels["column_" + col.name]);
label = label || "";
var title_cell = "<div class='" + cssClass + "' style='" + style + "' column_id='" + col.name + "'>" + label + sort + "</div>";
title_cells.push(title_cell);
if (filters.length >= i) {
var filter = filters[i];
var filter_cell = "<div class='" + cssClass + "' style='" + style + "'>" + filter.control + "</div>";
filter_cells.push(filter_cell);
}
}
this.$grid_scale.innerHTML = "<div class='gantt_grid_scale_row'>" + title_cells.join("") + "</div>" + (this.config.filter ? "<div class='gantt_grid_scale_row'>" + filter_cells.join("") + "</div>" : "");
this.$grid_scale.style.width = (width - 1) + "px";
if (this.config.scale_height_auto == true) {
var $grid_scale = $(this.$grid_scale);
$grid_scale.removeAttr("style");
this.config.scale_height = $grid_scale.height();
this.$grid_scale.style.height = (this.config.scale_height - 1) + "px";
this.$grid_scale.style.lineHeight = "1.42857143";
} else {
this.$grid_scale.style.height = (this.config.scale_height - 1) + "px";
this.$grid_scale.style.lineHeight = lineHeigth + "px";
}
};
gantt._render_grid_item = function (item) {
var btn_cell_width = 20;
if (!gantt._is_grid_visible())
return null;
var columns = this.getGridColumns();
var cells = [];
var width = 0;
for (var i = 0; i < columns.length; i++) {
var last = i == columns.length - 1;
var col = columns[i];
var cell;
var value;
var actions = null;
if (col.template)
value = col.template(item);
else
value = item[col.name];
if (value.actions) {
actions = value.actions;
value = value.content;
}
if (value instanceof Date)
value = this.templates.date_grid(value, item);
value = "<div class='gantt_tree_content'>" + value + "</div>";
var css = "gantt_cell" + (last ? " gantt_last_cell" : "");
var tree = "";
if (col.tree) {
for (var j = 0; j < item.$level; j++)
tree += this.templates.grid_indent(item);
var has_child = this._has_children(item.id);
if (has_child) {
tree += this.templates.grid_open(item);
tree += this.templates.grid_folder(item);
} else {
tree += this.templates.grid_blank(item);
tree += this.templates.grid_file(item);
}
}
var style = "width:" + (col.width - (actions ? btn_cell_width : 0) - (last ? 1 : 0)) + "px;";
if (this.defined(col.align))
style += "text-align:" + col.align + ";";
cell = "<div class='" + css + "' style='" + style + "'>" + tree + value + "</div>";
cells.push(cell);
if (actions) {
cells.push(actions);
}
}
var css = item.$index % 2 === 0 ? "" : " odd";
css += (item.$transparent) ? " gantt_transparent" : "";
css += (item.$dataprocessor_class ? " " + item.$dataprocessor_class : "");
if (this.templates.grid_row_class) {
var css_template = this.templates.grid_row_class.call(this, item.start_date, item.end_date, item);
if (css_template)
css += " " + css_template;
}
if (this.getState().selected_task == item.id) {
css += " gantt_selected";
}
var el = document.createElement("div");
el.className = "gantt_row" + css;
el.style.height = this.config.row_height + "px";
el.style.lineHeight = (gantt.config.row_height) + "px";
el.setAttribute(this.config.task_attribute, item.id);
el.innerHTML = cells.join("");
return el;
};
Собственно, код alxd.dhtmlxgantt.js частично заимствовал из jquery.yiigridview.js опять же, чтобы соблюсти преемственность конечного класса.
Стили
Конечно, из-за нахального перекрытия и изменения кода render'а DHTML Gantt, пришлось немного поправить стили. Не приложил их к своему исходному коду только потому, что в проекте easla.com они хранятся в отдельном less-файле. Изменения следующие:
@btn-cell-width: 20px;
.gantt-loading {
.gantt_container {
background: url('../images/loading.gif') no-repeat center center !important;
> .gantt_grid, > .gantt_task {
opacity: 0.5;
}
}
}
.gantt_grid_scale, .gantt_task_scale {
font-size: inherit;
background-color: @primary-color;
}
.gantt_grid_head_cell {
padding: 8px;
text-align: inherit;
overflow: inherit;
white-space: normal;
}
.gantt_row {
.btn-group {
vertical-align: inherit;
}
.btn-cell {
width: @btn-cell-width;
height: 100%;
.btn {
height: inherit;
line-height: inherit;
padding: 0px;
border-radius: 0px;
border: none;
width: 100%;
span.caret {
display: none;
}
}
i {
font-size: 14px;
}
}
}
.alxdgrid {
.gantt.table-footer {
margin-top: -1px;
}
}
Применение
Использовать получившуюся компоненту можно также, как CGridView, только надо указать taskAttributes. В моем случае код выглядит вот так:
$cnt = $viewpub->provider->totalItemCount;
$template = array();
$template[] = '{items}';
if ($cnt > 0) {
if ($viewpub->getShowAll()) {
$isShowAll = isset($_GET['showall']) && $_GET['showall'] == 1;
$params = array_merge((array)'', $_GET);
if ($isShowAll) {
unset($params['showall']);
} else {
$params['showall'] = 1;
}
$templateShowAll = CHtml::link(
$isShowAll ? '<i class="fa fa-files-o"></i>' : '<i class="fa fa-file-o"></i>',
$params,
array(
'id'=>'Viewpub_page_to_all',
'class'=>'btn btn-primary btn-outline pull-right show-all',
'title'=>$isShowAll ? Yii::t('Viewpub','Page-by-page') : Yii::t('Viewpub','All at once')
)
);
$template[] = '<div class="gantt table-footer clearfix">' . $templateShowAll . '{pager}{summary}</div>';
} else {
$template[] = '<div class="gantt table-footer clearfix">{pager}{summary}</div>';
}
}
$options = array(
'id' => 'viewpub_grid_' . $suffix,
'type' => BsHtml::GRID_TYPE_STRIPED,
'dataProcessorUrl'=>Yii::app()->createUrl('viewpub/ganttDataProcessor', array('viewpub_id'=>$viewpub->id, 'user_id'=>$user->id)),
'dataProvider' => $viewpub->provider,
'filter' => $viewpub->objectref,
'columns' => array_merge(
$cntCommands ? array($checkBoxColumn) : array(),
$viewpub->columns
),
'taskAttributes'=> $viewpub->getTaskAttributes(),
'itemsCssClass' => 'gantt-mono-primary',
'summaryCssClass'=>'hidden-xs table-summary',
'pagerCssClass'=>'table-pagination',
'loadingCssClass'=>'gantt-loading',
'enableSorting' => $viewpub->getSorting(),
'tree' => $viewpub->getTree(),
'scales' =>$viewpub->getScales(),
'template' => implode('', $template),
'pager' => array(
'class' => 'CLinkPager',
'maxButtonCount' => $isMobileClient ? 3 : 10,
'firstPageLabel' => ' <i class="fa fa-angle-double-left"></i> ',
'header' => '',
'hiddenPageCssClass' => 'disabled',
'lastPageLabel' => ' <i class="fa fa-angle-double-right"></i> ',
'nextPageLabel' => ' <i class="fa fa-angle-right"></i> ',//'>',
'selectedPageCssClass' => 'active',
'prevPageLabel' => ' <i class="fa fa-angle-left"></i> ',//'<',
'htmlOptions' => array('class' => 'pagination')
),
'updateSelector' => ($viewpub->getShowAll() ? '{page}, {sort}, a.show-all' : '{page},{sort}'),
'ajaxUpdateError'=>'function(request, textStatus, errorThrow, errorMessage){ EaslaAlert.add(request.status == 501 ? request.responseText : request.statusText+": "+extractExceptionText(request.responseText), {type: "danger"});}'
);
if ($cntCommands) {
$options['afterAjaxUpdate'] = 'function() { $(":checkbox").uniform();}';
$options['onTaskSelected'] = $options['onTaskOpened'] = $options['onTaskClosed'] = $options['onTaskDrag'] = 'function(id) { $(":checkbox").uniform();}';
}
$renderViewpub = $this->widget('ext.AlxdDhtmlxGantt.AlxdDhtmlxGantt', $options, true);
В easla.com в переменной $viewpub хранится класс, который формирует все необходимые параметры для отображения вида. Но в общем случае:
dataProvider может быть как CActiveDataProvider, так и CArrayDataProvider;
сolumns тот же самый columns, что и в обычной CGridView.
Showall – параметр, который обрабатывается на стороне провайдера и выставляет параметр pagination=false, таким образом исключая постраничное отображения и требуя отображения всех данных.
Итоги
В настоящий момент компонента используется в easla.com, как один из способов отображения информации для процессов управления задачами. Более подробно про управление задачами было описано в моей статье.
Выглядит все это удовольствие вот так:
По сути получился Microsoft Project, только, как метко заметил GarbageIntegrator, без навязанных бизнес-процессов, с неограниченным количеством полей и статусов, а главное, без надоевших багов.
Текущую версию AlxdDhtmlxGantt кому нужно может найти на github. Буду рад, если она кому-то пригодится.
Комментарии (9)
vtvz_ru
07.06.2016 17:11Немного печалит, что компонент написан для первой версии фреймворка. Конечно, ничего не мешает портировать его на вторую. Но тем не менее, первая версия уже активно устаревает.
Alxdhere
07.06.2016 17:12Согласен. Устаревает. Но продолжает, выполнять возложенные на нее обязанности. Старый конь борозды не портит!
P.S. Переход на Yii 2 только в планах.
Permyakov
08.06.2016 07:44А можно подробнее рассказать о лицензировании данного компонента? В тч
According to the terms of GNU GPLv2 you are required to share the source code of any app where you use open source Suite library.
Ваш код является открытым? :)
Могу ошибаться. Сами при выборе компонента в тч для ганта долго искали подходящий.Alxdhere
08.06.2016 07:45Хороший вопрос :)
Собственно, код системы открытым не является, только компоненты.Permyakov
08.06.2016 07:49+2Тогда рекомендую ознакомиться с их политикой лицензирования. Тут:
http://dhtmlx.com/docs/products/licenses.shtml
Несколько лет назад из всех возможных компонентов ганта (динамически редактируемых) только самые простые были совсем бесплатные. База на Jquery, ужасный дизайн, куча дефектов. Все более менее адекватное (ваш компонент, extgantt и т.д.) бесплатно только под GPL распространяются. А на сколько я смог осилить эту политику лицензирования — она совместима только сама с собой. Т.е. ваш продукт тоже должен быть GPL. Со всеми вытекающими.
IvanPanfilov
08.06.2016 08:52тоже как то довелось писать свою систему учета. пока писали ядро/фреймворк устарел.
даже решили переписывать на новую версию.
но оказалось что есть bitrix24 — бесплатно и намного лучше и богаче по функциям.
можно писать приложения и расширять через rest apiAlxdhere
08.06.2016 09:13+1Ну, easla.com — это не система учета, а скорее платформа, на которой можно реализовать любые бизнес-процессы организации. И главное реализовать так, как они есть, а не полагаться на «заранее продуманные» и зачастую неподходящие по разным причинам. Конечно, лучшие практики — это хорошо, но вымеренными дозами.
Обратная связь на Битрикс24 в рунете очень познавательная. Порой похожа на «ежики плакали но продолжали жрать кактусы».
Я не против Битрикс24, кому-то ее достаточно, но в нашем случае ее возможностей недостаточно.
GarbageIntegrator
Если говорить честно, получился пока не MS Project, но по интерфейсу теперь можно сделать что-то близкое к нему :-) А вот по логике работы — можно сделать нечто гораздо более подходящее к жизни.