Прикручування діаграми Гантта

При розробці системи документообігу виникла необхідність відображати дані у вигляді діаграми Гантта. Після нетривалих пошуків був знайдений відповідний безкоштовний компонент, який потрібно було прикрутити до «движку» easla.com.

Мій досвід прикручування JS-компоненти до движка на Yii з опис, кодом і прикладами під катом.

Насамперед скажу, що враховуючи специфіку розроблюваної системи документообігу першою думкою була розробка компоненти «з нуля» своїми силами. Але, звівши всі вимоги у довгий список, прикинувши обсяг робіт, кількість коду на PHP і JavaScript, трохи охолов. І, як і багато інших зрозумів, що доцільніше пошукати готові компоненти з необхідним функціоналом, навіть якщо вони будуть платними.

Вимоги до компоненті були наступні:
  • Відображення діаграми Гантта в стилі близькому easla.com
  • Зворотний зв'язок за допомогою зміни завдань мишкою
  • Безліч подієві ів
  • Швидкість роботи.
Враховуючи, що діаграма Гантта вельми популярний метод відображення інформації, компонент в Інтернеті виявилося дуже багато. Переглядав і відбирав їх напевно цілий день. Звичайно, спершу відмовлявся від самих простих, представляють виключно базовий функціонал, і поступово формував короткий список найбільш потужних і просунутих компонент.
Уважно вивчив тільки парочку компонент, однією з яких і була DHTMLX Gantt. На ній і зупинився.

Завдання

Вимоги до інтеграції компоненти були такі:
  • Інтеграція в easla.com у вигляді компоненти
  • Відображення даних посторінково/цілком
  • Фільтр і сортування засобами easla.com
  • Відображення шапки діаграми також, як в інших видах (таблицях)
  • Зворотний зв'язок при зміні завдань в діаграмі.
Перший пункт викликав ряд питань. Компонента на 100% клієнтська, тобто вся написана на JavaScript, а потрібно, щоб вона инициализировалась за допомогою PHP і брала безліч вхідних параметрів. На щастя, DHTMLX Gantt написана дуже якісно і з допомогою вхідних параметрів її можна налаштувати так, як треба.

Постраничное відображення – наступна головний біль. На форумі розробника звучали питання про «постраничности», але у відповідь тільки подив типу: «Навіщо це потрібно? Це ж порушує ідеологію діаграми Гантта!» Однак у моєму випадку без «постраничности» ніяк, тому схема реалізації теж була знайдена ще до реалізації.

Фільтр і сортування такий же непросте питання як і постраничное відображення. Сортування в компоненті є своя, але вона може бути використана тільки при відображенні даних у таблиці відразу. Інакше кажучи, при постраничном відображенні вбудована сортування працювати не буде. Фільтр працює аналогічно. Мені довелося витратити кілька днів на вивчення реалізації render'а в компоненті, щоб зрозуміти, чи вийде перетворювати шапку по-своєму. Приблизно так:


На щастя, зі зворотним зв'язком особливих проблем не побачив. У компоненті повно подієві ів, а також присутня 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. Не приховую, метод просто скопійований і трохи підправлений.
initColumns
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'іт:
renderItems
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. В ньому ж здійснюється завантаження всіх необхідних скриптів і стилів відображення, включаючи скрипт локалізації.
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. Спробував хірургічно їх перекрити, але нічого не вийшло, і в кінцевому рахунку повністю перекрив скопіювавши вихідний код і внести необхідні виправлення.
_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. Буду радий, якщо вона комусь знадобиться.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.