diff --git a/module/project/config.php b/module/project/config.php index 44e9de6622..79552b2742 100644 --- a/module/project/config.php +++ b/module/project/config.php @@ -85,3 +85,10 @@ $config->project->search['params']['finishedDate'] = array('operator' => '>=', $config->project->search['params']['closedDate'] = array('operator' => '>=', 'control' => 'input', 'values' => '', 'class' => 'date'); $config->project->search['params']['canceledDate'] = array('operator' => '>=', 'control' => 'input', 'values' => '', 'class' => 'date'); $config->project->search['params']['lastEditedDate']= array('operator' => '>=', 'control' => 'input', 'values' => '', 'class' => 'date'); + +$config->printKanban = new stdClass(); +$config->printKanban->col['story'] = 1; +$config->printKanban->col['wait'] = 2; +$config->printKanban->col['doing'] = 3; +$config->printKanban->col['done'] = 4; +$config->printKanban->col['closed'] = 5; diff --git a/module/project/control.php b/module/project/control.php index 3e44b663b6..5e66c9b931 100644 --- a/module/project/control.php +++ b/module/project/control.php @@ -691,8 +691,7 @@ class project extends control public function burn($projectID = 0, $type = 'noweekend', $interval = 0) { $this->loadModel('report'); - $project = $this->commonAction($projectID); - $projectInfo = $this->project->getByID($project->id); + $project = $this->commonAction($projectID); /* Header and position. */ $title = $project->name . $this->lang->colon . $this->lang->project->burn; @@ -700,23 +699,9 @@ class project extends control $position[] = $this->lang->project->burn; /* Get date list. */ + $projectInfo = $this->project->getByID($projectID); list($dateList, $interval) = $this->project->getDateList($projectInfo->begin, $projectInfo->end, $type, $interval, 'Y-m-d'); - - $sets = $this->project->getBurnDataFlot($project->id); - $limitJSON = '[]'; - $baselineJSON = '[]'; - - $firstBurn = empty($sets) ? 0 : reset($sets); - $firstTime = isset($firstBurn->value) ? $firstBurn->value : $this->project->getTotalEstimate($projectInfo->id); - $days = count($dateList) - 1; - $rate = round($firstTime / $days, 2); - $baselineJSON = '['; - foreach($dateList as $i => $date) $baselineJSON .= ($days - $i) * $rate . ','; - $baselineJSON = rtrim($baselineJSON, ',') . ']'; - - $chartData['labels'] = $this->report->convertFormat($dateList, DT_DATE4); - $chartData['burnLine'] = $this->report->createSingleJSON($sets, $dateList); - $chartData['baseLine'] = $baselineJSON; + $chartData = $this->project->buildBurnData($projectID, $dateList, $type); /* Set a space when assemble the string for english. */ $space = $this->app->getClientLang() == 'en' ? ' ' : ''; @@ -1192,6 +1177,146 @@ class project extends control $this->display(); } + /** + * Kanban. + * + * @param int $projectID + * @param string $orderBy + * @access public + * @return void + */ + public function kanban($projectID, $orderBy = 'pri_asc') + { + /* Compatibility IE8*/ + if(strpos($this->server->http_user_agent, 'MSIE 8.0') !== false) header("X-UA-Compatible: IE=EmulateIE7"); + + $this->project->setMenu($this->projects, $projectID); + $project = $this->loadModel('project')->getById($projectID); + $stories = $this->loadModel('story')->getProjectStories($projectID, $orderBy); + $tasks = $this->project->getKanbanTasks($projectID, "id"); + $bugs = $this->loadModel('bug')->getProjectBugs($projectID); + $stories = $this->project->getKanbanGroupData($stories, $tasks, $bugs); + + $this->view->title = $this->lang->project->kanban; + $this->view->position[] = html::a($this->createLink('project', 'browse', "projectID=$projectID"), $project->name); + $this->view->position[] = $this->lang->project->kanban; + $this->view->stories = $stories; + $this->view->realnames = $this->loadModel('user')->getPairs('noletter'); + $this->view->orderBy = $orderBy; + $this->view->projectID = $projectID; + $this->view->project = $project; + $this->display(); + } + + /** + * Print kanban. + * + * @param int $projectID + * @param string $orderBy + * @access public + * @return void + */ + public function printKanban($projectID, $orderBy = 'id_asc') + { + $this->view->title = $this->lang->project->printKanban; + $contents = array('story', 'wait', 'doing', 'done', 'cancel'); + + if($_POST) + { + $stories = $this->loadModel('story')->getProjectStories($projectID, $orderBy); + $storySpecs = $this->story->getStorySpecs(array_keys($stories)); + + $order = 1; + foreach($stories as $story) $story->order = $order++; + + $kanbanTasks = $this->project->getKanbanTasks($projectID, "id"); + $kanbanBugs = $this->loadModel('bug')->getProjectBugs($projectID); + + $users = array(); + $taskAndBugs = array(); + foreach($kanbanTasks as $task) + { + $storyID = $task->storyID; + $status = $task->status; + $users[] = $task->assignedTo; + + $taskAndBugs[$status]["task{$task->id}"] = $task; + } + foreach($kanbanBugs as $bug) + { + $storyID = $bug->story; + $status = $bug->status; + $status = $status == 'active' ? 'wait' : ($status == 'resolved' ? ($bug->resolution == 'postponed' ? 'cancel' : 'done') : $status); + $users[] = $bug->assignedTo; + + $taskAndBugs[$status]["bug{$bug->id}"] = $bug; + } + + $datas = array(); + foreach($contents as $content) + { + if($content != 'story' and !isset($taskAndBugs[$content])) continue; + $datas[$content] = $content == 'story' ? $stories : $taskAndBugs[$content]; + } + + unset($this->lang->story->stageList['']); + unset($this->lang->story->stageList['wait']); + unset($this->lang->story->stageList['planned']); + unset($this->lang->story->stageList['projected']); + unset($this->lang->story->stageList['released']); + unset($this->lang->task->statusList['']); + unset($this->lang->task->statusList['wait']); + unset($this->lang->task->statusList['closed']); + unset($this->lang->bug->statusList['']); + unset($this->lang->bug->statusList['closed']); + + $originalDatas = $datas; + if($this->post->content == 'increment') + { + $prevKanbans = $this->project->getPrevKanban($projectID); + foreach($datas as $type => $data) + { + if(isset($prevKanbans[$type])) + { + $prevData = $prevKanbans[$type]; + foreach($prevData as $id) + { + if(isset($data[$id])) unset($datas[$type][$id]); + } + } + } + } + + $this->project->saveKanbanData($projectID, $originalDatas); + + $hasBurn = $this->post->content == 'all'; + if($hasBurn) + { + /* Get date list. */ + $projectInfo = $this->project->getByID($projectID); + list($dateList) = $this->project->getDateList($projectInfo->begin, $projectInfo->end, 'noweekend'); + $chartData = $this->project->buildBurnData($projectID, $dateList, 'noweekend'); + } + + $this->view->hasBurn = $hasBurn; + $this->view->datas = $datas; + $this->view->chartData = $chartData; + $this->view->storySpecs = $storySpecs; + $this->view->realnames = $this->loadModel('user')->getRealNameAndEmails($users); + $this->view->projectID = $projectID; + + die($this->display()); + + } + + $this->project->setMenu($this->projects, $projectID); + $project = $this->project->getById($projectID); + + $this->view->position[] = html::a($this->createLink('project', 'browse', "projectID=$projectID"), $project->name); + $this->view->position[] = $this->lang->project->printKanban; + $this->display(); + } + /** * Delete a project. * @@ -1572,15 +1697,18 @@ class project extends control { if($confirm == 'no') { - echo js::confirm($this->lang->project->confirmUnlinkStory, $this->createLink('project', 'unlinkstory', "projectID=$projectID&storyID=$storyID&confirm=yes")); - exit; + die(js::confirm($this->lang->project->confirmUnlinkStory, $this->createLink('project', 'unlinkstory', "projectID=$projectID&storyID=$storyID&confirm=yes"))); } else { $this->project->unlinkStory($projectID, $storyID); - /* if ajax request, send result. */ - if($this->server->ajax) + /* if kanban then reload and if ajax request then send result. */ + if(isonlybody()) + { + die(js::reload('parent')); + } + elseif(helper::isAjaxRequest()) { if(dao::isError()) { @@ -1594,8 +1722,7 @@ class project extends control } $this->send($response); } - echo js::locate($this->app->session->storyList, 'parent'); - exit; + die(js::locate($this->app->session->storyList, 'parent')); } } diff --git a/module/project/css/kanban.css b/module/project/css/kanban.css new file mode 100644 index 0000000000..d58a096be6 --- /dev/null +++ b/module/project/css/kanban.css @@ -0,0 +1,76 @@ +table th.col-closed {width:15%} +#kanbanHeader th{border: 1px solid #ddd;} +#kanbanHeader .dropdown {display: inline-block; margin-left: 10px;} +#kanbanHeader .dropdown > a {color: #036} +#kanbanHeader th.col-closed {position: relative} +#kanbanHeader .actions {position: absolute; right: 0; top: 0; padding: 0px; background: #f1f1f1; border-left: 1px solid #ccc} +#kanbanHeader .actions .btn {display: block; border-radius: 0; width: 34px; height: 34px; position: relative; text-align: center; padding: 5px 0; color: #036} +#kanbanHeader .actions .btn-text {display: none} + +#kanbanWrapper {table-layout: fixed; border-top: none} +#kanbanWrapper thead th, #kanbanWrapper thead, #kanbanWrapper thead tr {padding: 0!important; border: none!important; height: 0!important; border-bottom: none!important} +#kanbanWrapper tbody td {vertical-align: top; padding: 6px} +#kanbanWrapper tbody th {background: #f1f1f1} +#kanbanWrapper .board-shadow {height: 0; opacity: 0; transition: opacity 0.2s;} +#kanbanWrapper tbody tr.dragging td.col-droppable {background: #fff4e5} +#kanbanWrapper tbody tr.dragging td.col-droppable.drop-to {background: #ffeacc} +#kanbanWrapper tbody tr.dragging td.col-droppable.drop-to .board-task + .board-shadow {margin-top: 6px;} +#kanbanWrapper tbody tr.dragging td.col-droppable.drop-to .board-shadow {height: 28px; background: #999; background: rgba(0,0,0,0.35); opacity: 1;} +#kanbanWrapper tbody tr.dragging td.col-droppable.drop-to.drag-from .board-shadow {display: none} +#kanbanWrapper .board.drag-from {opacity: 0.8} + +#kanban.dragging, .drag-shadow {cursor: move!important;} + +.board {background: #333; color: #f1f1f1; color: rgba(255,255,255,0.9); opacity: 1; padding: 3px 6px; transition: all 0.2s; position:relative;} +.board + .board {margin-top: 6px;} +.board:hover {opacity: 1} +.board a {color: #f1f1f1; color: rgba(255,255,255,0.9);} +.board a:hover {color: #fff} +.board-title, .board-header, .board-footer {padding: 2px 0} +.board-title {padding-right:35px;} +.board-actions {position: absolute; right: 3px; top: 3px} +.board-actions .btn {margin: 0; color: #fff; color: rgba(255,255,255,0.5); min-width: 20px} +.board-actions .btn:hover {background: #999; color: #fff; background: rgba(255,255,255,.2);} +.ie-8 .board-actions .btn {min-width: 10px} +.board-actions > .dropdown {display: inline-block; margin-left: -5px;} +.dropdown .caret {border-top-color: #999} +.dropdown-menu > a {display: block; padding: 3px 15px; color: #333; font-size: 12px} +.dropdown-menu > a:hover {background: #f1f1f1; color: #1a53ff; text-decoration: none} +.dropdown-menu > a.disabled, .dropdown-menu > a.disabled:hover, .dropdown-menu > a.active {color: #aaa; background: none} +.board .board-footer {display: none; opacity: 0.9} +.board.show-info .board-footer {display: block; margin: 0 -6px -3px -6px; padding: 5px; background: rgba(0,0,0,0.2);} +.board-footer .board-id {display: inline-block; border: 1px solid #aaa; font-weight: normal; padding: 0px 4px; text-align: center; min-width: 20px; line-height: 16px; height: 17px; margin-right: 10px; font-size: 12px;} +.board-bug-closed .board-footer .bug-pri, .board-task-closed .board-footer .task-pri {border-color: #666} +.board-bug-closed .board-footer .board-id, .board-task-closed .board-footer .board-id {border-color: #777} +.board-footer .bug-pri, .board-footer .task-pri, .board-footer .story-pri {margin-right: 10px;} + +.board-story {background: #dd7503} +.board-bug-wait,.board-task-wait {background: #2b529c} +.board-bug-doing,.board-task-doing {background: #D2322D} +.board-bug-done,.board-task-done {background: #229F24} +.board-bug-closed,.board-task-closed {background: #ddd; color: #333} +.board-bug-closed .board-title,.board-task-closed .board-title {border-bottom-color: #333} +.board-bug-closed a,.board-task-closed a {color: #222} +.board-bug-closed a:hover,.board-task-closed a:hover {color: #000} +.board-bug-closed .board-actions .btn,.board-task-closed .board-actions .btn {color: #666} +.board-bug-closed .board-actions .btn:hover,.board-task-closed .board-actions .btn:hover {color: #333; background: #ccc} + +.pri-0:before, .pri-3:before {content: "3";} +.pri-1:before {content: "1";} +.pri-2:before {content: "2";} +.pri-4:before {content: "4";} +.pri-5:before {content: "5";} +.pri-6:before {content: "6";} + +.story-pri {display: inline-block; width: 16px; height: 16px; text-align: center; border-radius: 8px; line-height: 16px; font-size: 12px} +.story-pri.pri-0 {background-color:#b56a00;} +.story-pri.pri-1 {background-color:#E30413;} +.story-pri.pri-2 {background-color:#E84E0E;} +.story-pri.pri-3 {background-color:#b56a00;} +.story-pri.pri-4 {background-color:#795523;} + +.bug-pri,.task-pri {display: inline-block; width: 16px; height: 16px; text-align: center; border-radius: 8px; border: 1px solid #ddd; font-size: 12px} + +.affix {position:fixed; top:0px; width:95%;z-index:1030;} +#kanbanHeader.affix {box-shadow: 0 3px 10px rgba(0,0,0,.1); background: } +#kanbanHeader.affix th {border-bottom: 1px solid #bbb; background: #ebf5f9} diff --git a/module/project/js/kanban.js b/module/project/js/kanban.js new file mode 100644 index 0000000000..91b0dcd287 --- /dev/null +++ b/module/project/js/kanban.js @@ -0,0 +1,208 @@ +$(function() +{ + var boardID = ''; + var onlybody = config.requestType == 'GET' ? "&onlybody=yes" : "?onlybody=yes"; + $('#printKanban').modalTrigger({type:'iframe', width: 400, url: createLink('project', 'printKanban', 'projectID=' + projectID), icon: 'print'}); + $(".kanbanFrame").modalTrigger({type: 'iframe', width: '80%', afterShow:function(){ $('#ajaxModal').data('cancel-reload', true)}, afterHidden: function(){refresh()}}); + + $.cookie('selfClose', 0, {expires:config.cookieLife, path:config.webRoot}); + + var $kanban = $('#kanban'); + var $kanbanWrapper = $('#kanbanWrapper'); + + initBoards(); + + var statusMap = + { + task: + { + wait : {doing: 'start', done: 'finish', cancel: 'cancel'}, + doing : {done: 'finish'}, + done : {doing: 'activate', closed: 'close'}, + cancel : {doing: 'activate', closed: 'close'}, + closed : {doing: 'activate'} + }, + bug: + { + wait : {done: 'resolve', cancel: 'resolve'}, + doing : {}, + done : {wait: 'activate', closed: 'close'}, + cancel : {wait: 'activate', closed: 'close'}, + closed : {wait: 'activate'} + } + }; + + var lastOperation; + + function dropTo(id, from, to, type) + { + if(statusMap[type][from] && statusMap[type][from][to]) + { + lastOperation = {id: id, from: from, to: to}; + $.modalTrigger({type: 'iframe', url: createLink(type, statusMap[type][from][to], 'id=' + id) + onlybody, afterShow:function(){ $('#ajaxModal').data('cancel-reload', true)}, afterHidden: function() + { + var selfClose = $.cookie('selfClose'); + $.cookie('selfClose', 0, {expires:config.cookieLife, path:config.webRoot}); + if(selfClose != 1 && lastOperation) + { + $item = $('#' + type + '-' + lastOperation.id); + for(var status in statusMap[type]) + { + $item.removeClass('board-' + type + '-' + status); + } + $item.addClass('board-' + type + '-' + lastOperation.from).insertBefore($item.closest('tr').find('.col-'+lastOperation.from + ' .board-shadow')); + } + else + { + $.get(createLink(type, 'ajaxGetByID', 'id=' + id), function(data) + { + $('div#' + type + '-' + id).find('.' + type + '-assignedTo small').html(data.assignedTo); + if(type == 'task') + { + $('div#task-' + id).find('.task-left').html(data.left + 'h'); + if(data.story)$('div.board-story[data-id="' + data.story + '"]').find('.story-stage').html(data.storyStage); + } + }, 'json'); + } + }}); + return true; + } + return false; + } + + function initBoards() + { + $('.col-droppable').append('
'); + + var $boardTasks = $kanban.find('.board-task'); + $boardTasks.droppable( + { + target: '.col-droppable', + flex: true, + before: function(e) + { + if(e.element.find('.dropdown.open').length) return false; + }, + start: function(e) + { + e.element.closest('td').addClass('drag-from').closest('tr').addClass('dragging'); + $kanban.addClass('dragging').find('.board-item-shadow').height(e.element.outerHeight()); + }, + drag: function(e) + { + if(e.isNew) + { + var $dargShadow = $('.drag-shadow.board-task'); + for(var status in statusMap['task']) + { + $dargShadow.removeClass('board-task-' + status); + } + $dargShadow.addClass('board-task-' + e.target.data('id')); + } + }, + drop: function(e) + { + if(e.isNew && e.element.closest('tr').data('id') == e.target.closest('tr').data('id')) + { + var result = dropTo(e.element.data('id'), e.element.closest('td').data('id'), e.target.data('id'), 'task'); + if(result !== false) + { + for(var status in statusMap['task']) + { + e.element.removeClass('board-task-' + status); + } + e.element.addClass('board-task-' + e.target.data('id')).insertBefore(e.target.find('.board-shadow')); + } + } + }, + finish: function(e) + { + $kanban.removeClass('dragging drop-in'); + $kanbanWrapper.find('tr.dragging').removeClass('dragging').find('.drop-in, .drag-from').removeClass('drop-in drag-from'); + } + }); + + var $boardBugs = $kanban.find('.board-bug'); + $boardBugs.droppable( + { + target: '.col-droppable', + flex: true, + start: function(e) + { + e.element.closest('td').addClass('drag-from').closest('tr').addClass('dragging'); + $kanban.addClass('dragging').find('.board-item-shadow').height(e.element.outerHeight()); + }, + drag: function(e) + { + if(e.isNew) + { + var $dargShadow = $('.drag-shadow.board-bug'); + for(var status in statusMap['bug']) + { + $dargShadow.removeClass('board-bug-' + status); + } + $dargShadow.addClass('board-bug-' + e.target.data('id')); + } + }, + drop: function(e) + { + if(e.isNew && e.element.closest('tr').data('id') == e.target.closest('tr').data('id')) + { + var result = dropTo(e.element.data('id'), e.element.closest('td').data('id'), e.target.data('id'), 'bug'); + if(result !== false) + { + for(var status in statusMap['bug']) + { + e.element.removeClass('board-bug-' + status); + } + e.element.addClass('board-bug-' + e.target.data('id')).insertBefore(e.target.find('.board-shadow')); + } + } + }, + finish: function(e) + { + $kanban.removeClass('dragging drop-in'); + $kanbanWrapper.find('tr.dragging').removeClass('dragging').find('.drop-in, .drag-from').removeClass('drop-in drag-from'); + } + }); + } + + function refresh() + { + var selfClose = $.cookie('selfClose'); + $.cookie('selfClose', 0, {expires:config.cookieLife, path:config.webRoot}); + if(selfClose == 1) + { + $('#kanbanWrapper').wrap(""); + $('#tempDIV').load(location.href + ' #kanbanWrapper', function() + { + $('#kanbanWrapper').unwrap(); + initBoards() + $(".kanbanFrame").modalTrigger({type: 'iframe', width: '80%', afterShow:function(){ $('#ajaxModal').data('cancel-reload', true)}, afterHidden: function(){refresh()}}); + }); + } + } + + var fixH = $("#kanbanHeader").offset().top; + $(window).scroll(function() + { + var scroH = $(this).scrollTop(); + if(scroH>=fixH) + { + $("#kanbanHeader").addClass('affix'); + $("#kanbanHeader").width($('#kanbanWrapper').width()); + } + else if(scroH| + + | + + + +task->statusList[$col];?> | + +task->statusList[$endCol];?>
+
+ " . $lang->project->printKanban . '', "id='printKanban' title='" . $lang->project->printKanban . "'", 'btn btn-sm btn-link');?>
+
+ |
+
|---|
| + + | + |
|---|---|
|
+ id)):?>
+
+
+
+
+ createLink('story', 'view', "storyID=$story->id", '', true), $story->title, '', 'class="kanbanFrame" title="' . $story->title . '"');?>
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ tasks[$col])):?>
+ tasks[$col] as $task):?>
+
+
+
+
+ bugs[$col])):?>
+ bugs[$col] as $bug):?>
+
+ createLink('task', 'view', "taskID=$task->id", '', true), $task->name, '', 'class="kanbanFrame" title="' . $task->name . '"');?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ createLink('bug', 'view', "bugID=$bug->id", '', true), $bug->title, '', 'class="kanbanFrame" title="' . $bug->title . '"');?>
+
+
+
+
+
+
+
+
+
+ |
+
+
|
+
+
+
+
+
+ story : $content->storyID;
+ echo $lang->story->common . "#" . (empty($story) ? '0' : $story) . '';
+ echo ' ';
+ }
+ ?>
+ story->common : ($dataType == 'task' ? $lang->task->common : $lang->bug->common)?>
+ #id;?>
+ Ppri ? $content->pri : ' ';?>
+ '>
+
+
+ '>
+ title : $content->name;
+ echo $col == 'story' ? mb_substr($title, 0, 22, 'utf8') : mb_substr($title, 0, 85, 'utf8')
+ ?>
+
+
+ id]->spec : $content->steps;?>
+ "), 0, 90, 'utf8')?> |
+
+
+