Add Gantt chart for projects

This commit is contained in:
Frederic Guillot 2015-08-14 17:03:55 -04:00
parent c6a4fbb386
commit 17a3781bd8
47 changed files with 1169 additions and 102 deletions

View File

@ -3,6 +3,7 @@ Version 1.0.18 (unreleased)
New features:
* Add Gantt chart for projects
* Add new role "Project Administrator"
* Add login bruteforce protection with captcha and account lockdown
* Add new api procedures: getDefaultTaskColor(), getDefaultTaskColors() and getColorList()

114
app/Controller/Gantt.php Normal file
View File

@ -0,0 +1,114 @@
<?php
namespace Controller;
use Model\Task;
/**
* Gantt controller
*
* @package controller
* @author Frederic Guillot
*/
class Gantt extends Base
{
/**
* Show Gantt chart for projects
*/
public function project()
{
$project = $this->getProject();
$sorting = $this->request->getStringParam('sorting', 'board');
$filter = $this->taskFilter->gantt()->filterByProject($project['id'])->filterByStatus(Task::STATUS_OPEN);
if ($sorting === 'date') {
$filter->query->asc(Task::TABLE.'.date_started')->asc(Task::TABLE.'.date_creation');
}
else {
$filter->query->asc('column_position')->asc(Task::TABLE.'.position');
}
$this->response->html($this->template->layout('gantt/project', array(
'sorting' => $sorting,
'tasks' => $filter->toGanttBars(),
'project' => $project,
'title' => t('Gantt chart for %s', $project['name']),
'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()),
)));
}
/**
* Save new task start date and due date
*/
public function saveDate()
{
$project = $this->getProject();
$values = $this->request->getJson();
$result = $this->taskModification->update(array(
'id' => $values['id'],
'date_started' => strtotime($values['start']),
'date_due' => strtotime($values['end']),
));
if (! $result) {
$this->response->json(array('message' => 'Unable to save task'), 400);
}
$this->response->json(array('message' => 'OK'), 201);
}
/**
* Simplified form to create a new task
*
* @access public
*/
public function task(array $values = array(), array $errors = array())
{
$project = $this->getProject();
$this->response->html($this->template->render('gantt/task_creation', array(
'errors' => $errors,
'values' => $values + array(
'project_id' => $project['id'],
'column_id' => $this->board->getFirstColumn($project['id']),
'position' => 1
),
'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true),
'colors_list' => $this->color->getList(),
'categories_list' => $this->category->getList($project['id']),
'swimlanes_list' => $this->swimlane->getList($project['id'], false, true),
'date_format' => $this->config->get('application_date_format'),
'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => $project['name'].' &gt; '.t('New task')
)));
}
/**
* Validate and save a new task
*
* @access public
*/
public function saveTask()
{
$project = $this->getProject();
$values = $this->request->getValues();
list($valid, $errors) = $this->taskValidator->validateCreation($values);
if ($valid) {
$task_id = $this->taskCreation->create($values);
if ($task_id !== false) {
$this->session->flash(t('Task created successfully.'));
$this->response->redirect($this->helper->url->to('gantt', 'project', array('project_id' => $project['id'])));
}
else {
$this->session->flashError(t('Unable to create your task.'));
}
}
$this->task($values, $errors);
}
}

View File

@ -2,8 +2,6 @@
namespace Controller;
use Model\Project as ProjectModel;
/**
* Task Creation controller
*
@ -38,7 +36,6 @@ class Taskcreation extends Base
'ajax' => $this->request->isAjax(),
'errors' => $errors,
'values' => $values + array('project_id' => $project['id']),
'projects_list' => $this->project->getListByStatus(ProjectModel::ACTIVE),
'columns_list' => $this->board->getColumnsList($project['id']),
'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true),
'colors_list' => $this->color->getList(),

View File

@ -65,6 +65,7 @@ class Acl extends Base
'project' => array('edit', 'update', 'share', 'integration', 'users', 'alloweverybody', 'allow', 'setowner', 'revoke', 'duplicate', 'disable', 'enable'),
'swimlane' => '*',
'budget' => '*',
'gantt' => '*',
);
/**

View File

@ -121,7 +121,7 @@ class Task extends Base
*/
public function getRecurrenceStatusList()
{
return array (
return array(
Task::RECURRING_STATUS_NONE => t('No'),
Task::RECURRING_STATUS_PENDING => t('Yes'),
);
@ -135,7 +135,7 @@ class Task extends Base
*/
public function getRecurrenceTriggerList()
{
return array (
return array(
Task::RECURRING_TRIGGER_FIRST_COLUMN => t('When task is moved from first column'),
Task::RECURRING_TRIGGER_LAST_COLUMN => t('When task is moved to last column'),
Task::RECURRING_TRIGGER_CLOSE => t('When task is closed'),
@ -150,7 +150,7 @@ class Task extends Base
*/
public function getRecurrenceBasedateList()
{
return array (
return array(
Task::RECURRING_BASEDATE_DUEDATE => t('Existing due date'),
Task::RECURRING_BASEDATE_TRIGGERDATE => t('Action date'),
);
@ -164,10 +164,37 @@ class Task extends Base
*/
public function getRecurrenceTimeframeList()
{
return array (
return array(
Task::RECURRING_TIMEFRAME_DAYS => t('Day(s)'),
Task::RECURRING_TIMEFRAME_MONTHS => t('Month(s)'),
Task::RECURRING_TIMEFRAME_YEARS => t('Year(s)'),
);
}
/**
* Get task progress based on the column position
*
* @access public
* @param array $task
* @param array $columns
* @return integer
*/
public function getProgress(array $task, array $columns)
{
if ($task['is_active'] == self::STATUS_CLOSED) {
return 100;
}
$position = 0;
foreach ($columns as $column_id => $column_title) {
if ($column_id == $task['column_id']) {
break;
}
$position++;
}
return (int) ($position * 100) / count($columns);
}
}

View File

@ -25,10 +25,17 @@ class TaskCreation extends Base
return 0;
}
$position = empty($values['position']) ? 0 : $values['position'];
$this->prepare($values);
$task_id = $this->persist(Task::TABLE, $values);
if ($task_id !== false) {
if ($position > 0 && $values['position'] > 1) {
$this->taskPosition->movePosition($values['project_id'], $task_id, $values['column_id'], $position, $values['swimlane_id'], false);
}
$this->fireEvents($task_id, $values);
}
@ -46,7 +53,7 @@ class TaskCreation extends Base
$this->dateParser->convert($values, array('date_due'));
$this->dateParser->convert($values, array('date_started'), true);
$this->removeFields($values, array('another_task'));
$this->resetFields($values, array('creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
$this->resetFields($values, array('date_started', 'creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
if (empty($values['column_id'])) {
$values['column_id'] = $this->board->getFirstColumn($values['project_id']);

View File

@ -86,6 +86,38 @@ class TaskFilter extends Base
return $this;
}
/**
* Prepare filter for Gantt chart
*
* @access public
* @return TaskFilter
*/
public function gantt()
{
$this->query = $this->db->table(Task::TABLE);
$this->query->join(Board::TABLE, 'id', 'column_id', Task::TABLE);
$this->query->join(User::TABLE, 'id', 'owner_id', Task::TABLE);
$this->query->columns(
Task::TABLE.'.id',
Task::TABLE.'.title',
Task::TABLE.'.project_id',
Task::TABLE.'.column_id',
Task::TABLE.'.color_id',
Task::TABLE.'.date_started',
Task::TABLE.'.date_due',
Task::TABLE.'.date_creation',
Task::TABLE.'.is_active',
Task::TABLE.'.position',
Board::TABLE.'.position AS column_position',
Board::TABLE.'.title AS column_title',
User::TABLE.'.name AS assignee_name',
User::TABLE.'.username AS assignee_username'
);
return $this;
}
/**
* Create a new query
*
@ -674,6 +706,50 @@ class TaskFilter extends Base
});
}
/**
* Format tasks to be displayed in the Gantt chart
*
* @access public
* @return array
*/
public function toGanttBars()
{
$bars = array();
$columns = array();
foreach ($this->query->findAll() as $task) {
if (! isset($column_count[$task['project_id']])) {
$columns[$task['project_id']] = $this->board->getColumnsList($task['project_id']);
}
$start = $task['date_started'] ?: time();
$end = $task['date_due'] ?: $start;
$bars[] = array(
'id' => $task['id'],
'title' => $task['title'],
'start' => array(
(int) date('Y', $start),
(int) date('n', $start),
(int) date('j', $start),
),
'end' => array(
(int) date('Y', $end),
(int) date('n', $end),
(int) date('j', $end),
),
'column_title' => $task['column_title'],
'assignee' => $task['assignee_name'] ?: $task['assignee_username'],
'progress' => $this->task->getProgress($task, $columns[$task['project_id']]).'%',
'link' => $this->helper->url->href('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])),
'color' => $this->color->getColorProperties($task['color_id']),
'not_defined' => empty($task['date_due']) || empty($task['date_started']),
);
}
return $bars;
}
/**
* Format the results to the ajax autocompletion
*

View File

@ -10,7 +10,7 @@ const VERSION = 63;
function version_63($pdo)
{
$pdo->exec("ALTER TABLE users ADD COLUMN is_project_admin INTEGER DEFAULT 0");
$pdo->exec("ALTER TABLE users ADD COLUMN is_project_admin BOOLEAN DEFAULT '0'");
}
function version_62($pdo)

View File

@ -0,0 +1,49 @@
<section id="main">
<div class="page-header">
<ul>
<li>
<span class="dropdown">
<span>
<i class="fa fa-caret-down"></i> <a href="#" class="dropdown-menu"><?= t('Actions') ?></a>
<ul>
<?= $this->render('project/dropdown', array('project' => $project)) ?>
</ul>
</span>
</span>
</li>
<li>
<i class="fa fa-th fa-fw"></i>
<?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $project['id'])) ?>
</li>
</ul>
<ul class="views toolbar">
<li <?= $sorting === 'board' ? 'class="active"' : '' ?>>
<i class="fa fa-sort-numeric-asc fa-fw"></i>
<?= $this->url->link(t('Sort by position'), 'gantt', 'project', array('project_id' => $project['id'], 'sorting' => 'board')) ?>
</li>
<li <?= $sorting === 'date' ? 'class="active"' : '' ?>>
<i class="fa fa-sort-amount-asc fa-fw"></i>
<?= $this->url->link(t('Sort by date'), 'gantt', 'project', array('project_id' => $project['id'], 'sorting' => 'date')) ?>
</li>
<li>
<i class="fa fa-plus fa-fw"></i>
<?= $this->url->link(t('Add task'), 'gantt', 'task', array('project_id' => $project['id']), false, 'popover') ?>
</li>
</ul>
</div>
<?php if (! empty($tasks)): ?>
<div
id="gantt-chart"
data-tasks='<?= json_encode($tasks) ?>'
data-save-url="<?= $this->url->href('gantt', 'saveDate', array('project_id' => $project['id'])) ?>"
data-label-start-date="<?= t('Start date:') ?>"
data-label-end-date="<?= t('Due date:') ?>"
data-label-assignee="<?= t('Assignee:') ?>"
data-label-not-defined="<?= t('There is no start date or due date for this task.') ?>"
></div>
<p class="alert alert-info"><?= t('Moving or resizing a task will change the start and due date of the task.') ?></p>
<?php else: ?>
<p class="alert"><?= t('There is no task in your project.') ?></p>
<?php endif ?>
</section>

View File

@ -0,0 +1,65 @@
<div class="page-header">
<h2><?= t('New task') ?></h2>
</div>
<form method="post" action="<?= $this->url->href('gantt', 'saveTask', array('project_id' => $values['project_id'])) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('project_id', $values) ?>
<?= $this->form->hidden('column_id', $values) ?>
<?= $this->form->hidden('position', $values) ?>
<div class="form-column">
<?= $this->form->label(t('Title'), 'title') ?>
<?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?>
<?= $this->form->label(t('Description'), 'description') ?>
<div class="form-tabs">
<div class="write-area">
<?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"')) ?>
</div>
<div class="preview-area">
<div class="markdown"></div>
</div>
<ul class="form-tabs-nav">
<li class="form-tab form-tab-selected">
<i class="fa fa-pencil-square-o fa-fw"></i><a id="markdown-write" href="#"><?= t('Write') ?></a>
</li>
<li class="form-tab">
<a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a>
</li>
</ul>
</div>
</div>
<div class="form-column">
<?= $this->form->label(t('Assignee'), 'owner_id') ?>
<?= $this->form->select('owner_id', $users_list, $values, $errors, array('tabindex="3"')) ?><br/>
<?= $this->form->label(t('Category'), 'category_id') ?>
<?= $this->form->select('category_id', $categories_list, $values, $errors, array('tabindex="4"')) ?><br/>
<?php if (! (count($swimlanes_list) === 1 && key($swimlanes_list) === 0)): ?>
<?= $this->form->label(t('Swimlane'), 'swimlane_id') ?>
<?= $this->form->select('swimlane_id', $swimlanes_list, $values, $errors, array('tabindex="5"')) ?><br/>
<?php endif ?>
<?= $this->form->label(t('Color'), 'color_id') ?>
<?= $this->form->select('color_id', $colors_list, $values, $errors, array('tabindex="7"')) ?><br/>
<?= $this->form->label(t('Complexity'), 'score') ?>
<?= $this->form->number('score', $values, $errors, array('tabindex="8"')) ?><br/>
<?= $this->form->label(t('Start Date'), 'date_started') ?>
<?= $this->form->text('date_started', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="9"'), 'form-date') ?>
<?= $this->form->label(t('Due Date'), 'date_due') ?>
<?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="10"'), 'form-date') ?><br/>
<div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
</div>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" tabindex="11"/>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $values['project_id']), false, 'close-popover') ?>
</div>
</form>

View File

@ -14,6 +14,10 @@
<i class="fa fa-line-chart fa-fw"></i>
<?= $this->url->link(t('Analytics'), 'analytic', 'tasks', array('project_id' => $project['id'])) ?>
</li>
<li>
<i class="fa fa-sliders fa-fw"></i>
<?= $this->url->link(t('Gantt chart'), 'gantt', 'project', array('project_id' => $project['id'])) ?>
</li>
<li>
<i class="fa fa-pie-chart fa-fw"></i>
<?= $this->url->link(t('Budget'), 'budget', 'index', array('project_id' => $project['id'])) ?>

View File

@ -126,6 +126,10 @@ if (ENABLE_URL_REWRITE) {
$container['router']->addRoute('l/:project_id', 'listing', 'show', array('project_id'));
$container['router']->addRoute('list/:project_id/:search', 'listing', 'show', array('project_id', 'search'));
// Gantt routes
$container['router']->addRoute('gantt/:project_id', 'gantt', 'project', array('project_id'));
$container['router']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project', array('project_id', 'sorting'));
// Subtask routes
$container['router']->addRoute('project/:project_id/task/:task_id/subtask/create', 'subtask', 'create', array('project_id', 'task_id'));
$container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/remove', 'subtask', 'confirm', array('project_id', 'task_id', 'subtask_id'));

File diff suppressed because one or more lines are too long

BIN
assets/css/images/ui-bg_flat_0_aaaaaa_40x100.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 212 B

BIN
assets/css/images/ui-bg_flat_75_ffffff_40x100.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

BIN
assets/css/images/ui-bg_glass_55_fbf9ee_1x400.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

BIN
assets/css/images/ui-bg_glass_65_ffffff_1x400.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 207 B

BIN
assets/css/images/ui-bg_glass_75_dadada_1x400.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

BIN
assets/css/images/ui-bg_glass_75_e6e6e6_1x400.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

BIN
assets/css/images/ui-bg_glass_95_fef1ec_1x400.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

BIN
assets/css/images/ui-icons_222222_256x240.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
assets/css/images/ui-icons_2e83ff_256x240.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
assets/css/images/ui-icons_454545_256x240.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
assets/css/images/ui-icons_888888_256x240.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
assets/css/images/ui-icons_cd0a0a_256x240.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,8 @@
.toolbar {
font-size: 0.9em;
padding-top: 5px;
}
.views {
display: inline-block;
margin-right: 10px;
@ -9,6 +14,7 @@
padding-right: 12px;
padding-top: 5px;
padding-bottom: 5px;
display: inline;
}
.views li.active a {

130
assets/css/src/gantt.css Normal file
View File

@ -0,0 +1,130 @@
/* Based on jQuery.ganttView v.0.8.0 Copyright (c) 2010 JC Grubbs - jc.grubbs@devmynd.com - MIT License */
div.ganttview-hzheader-month,
div.ganttview-hzheader-day,
div.ganttview-vtheader,
div.ganttview-vtheader-item-name,
div.ganttview-vtheader-series,
div.ganttview-grid,
div.ganttview-grid-row-cell {
float: left;
}
div.ganttview-hzheader-month,
div.ganttview-hzheader-day {
text-align: center;
}
div.ganttview-grid-row-cell.last,
div.ganttview-hzheader-day.last,
div.ganttview-hzheader-month.last {
border-right: none;
}
div.ganttview {
border: 1px solid #999;
}
/* Horizontal Header */
div.ganttview-hzheader-month {
width: 60px;
height: 20px;
border-right: 1px solid #d0d0d0;
line-height: 20px;
}
div.ganttview-hzheader-day {
width: 20px;
height: 20px;
border-right: 1px solid #f0f0f0;
border-top: 1px solid #d0d0d0;
line-height: 20px;
color: #777;
}
/* Vertical Header */
div.ganttview-vtheader {
margin-top: 41px;
width: 400px;
overflow: hidden;
background-color: #fff;
}
div.ganttview-vtheader-item {
color: #666;
}
div.ganttview-vtheader-series-name {
width: 400px;
height: 31px;
line-height: 31px;
padding-left: 3px;
border-top: 1px solid #d0d0d0;
font-size: 0.9em;
text-overflow: ellipsis;
overflow: auto;
white-space: nowrap;
}
div.ganttview-vtheader-series-name a {
color: #666;
text-decoration: none;
}
div.ganttview-vtheader-series-name a:hover {
text-decoration: underline;
}
/* Slider */
div.ganttview-slide-container {
overflow: auto;
border-left: 1px solid #999;
}
/* Grid */
div.ganttview-grid-row-cell {
width: 20px;
height: 31px;
border-right: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0;
}
div.ganttview-grid-row-cell.ganttview-weekend {
background-color: #fafafa;
}
/* Blocks */
div.ganttview-blocks {
margin-top: 40px;
}
div.ganttview-block-container {
height: 28px;
padding-top: 4px;
}
div.ganttview-block {
position: relative;
height: 25px;
background-color: #E5ECF9;
border: 1px solid #c0c0c0;
border-radius: 3px;
cursor: move;
}
div.ganttview-block-not-defined {
border-color: #000;
background-color: #000;
}
div.ganttview-block-text {
position: absolute;
height: 12px;
font-size: 0.7em;
color: #999;
padding: 2px 3px;
}
/* Adjustments for jQuery UI Styling */
div.ganttview-block div.ui-resizable-handle.ui-resizable-s {
bottom: -0;
}

View File

@ -89,6 +89,7 @@ nav .active a {
.page-header ul {
text-align: left;
margin-top: 5px;
display: inline-block;
}
.page-header li {

View File

@ -8,7 +8,7 @@
div.ui-tooltip {
min-width: 200px;
max-width: 600px;
font-size: 0.95em;
font-size: 0.85em;
}
.tooltip-arrow {

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
/*! jQuery UI - v1.11.3 - 2015-02-14
/*! jQuery UI - v1.11.4 - 2015-08-09
* http://jqueryui.com
* Copyright 2015 jQuery Foundation and other contributors; Licensed MIT */

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,6 @@ function App() {
this.popover = new Popover(this);
this.keyboardShortcuts();
this.boardSelector();
this.listen();
this.poll();
// Alert box fadeout
@ -28,8 +27,6 @@ function App() {
}
App.prototype.listen = function() {
$(document).off();
this.popover.listen();
this.markdown.listen();
this.sidebar.listen();
@ -42,6 +39,11 @@ App.prototype.listen = function() {
this.focus();
};
App.prototype.refresh = function() {
$(document).off();
this.listen();
};
App.prototype.focus = function() {
// Autofocus fields (html5 autofocus works only with page onload)

View File

@ -1,7 +1,8 @@
function AvgTimeColumnChart() {
function AvgTimeColumnChart(app) {
this.app = app;
}
AvgTimeColumnChart.prototype.execute = function(app) {
AvgTimeColumnChart.prototype.execute = function() {
var metrics = $("#chart").data("metrics");
var plots = [$("#chart").data("label")];
var categories = [];
@ -28,7 +29,7 @@ AvgTimeColumnChart.prototype.execute = function(app) {
},
y: {
tick: {
format: app.formatDuration
format: this.app.formatDuration
}
}
},

View File

@ -1,10 +1,9 @@
function Board() {
this.app = null;
function Board(app) {
this.app = app;
this.checkInterval = null;
}
Board.prototype.execute = function(app) {
this.app = app;
Board.prototype.execute = function() {
this.app.swimlane.refresh();
this.app.swimlane.listen();
this.poll();
@ -33,7 +32,7 @@ Board.prototype.check = function() {
$.ajax({
cache: false,
url: $("#board").attr("data-check-url"),
url: $("#board").data("check-url"),
statusCode: {
200: function(data) { self.refresh(data); },
304: function () { self.app.hideLoadingIcon(); }
@ -47,7 +46,7 @@ Board.prototype.save = function(taskId, columnId, position, swimlaneId) {
$.ajax({
cache: false,
url: $("#board").attr("data-save-url"),
url: $("#board").data("save-url"),
contentType: "application/json",
type: "POST",
processData: false,
@ -65,7 +64,7 @@ Board.prototype.save = function(taskId, columnId, position, swimlaneId) {
Board.prototype.refresh = function(data) {
$("#board-container").replaceWith(data);
this.app.listen();
this.app.refresh();
this.app.swimlane.refresh();
this.app.swimlane.listen();
this.resizeColumnHeight();

402
assets/js/src/Gantt.js Normal file
View File

@ -0,0 +1,402 @@
// Based on jQuery.ganttView v.0.8.8 Copyright (c) 2010 JC Grubbs - jc.grubbs@devmynd.com - MIT License
function Gantt(app) {
this.app = app;
this.data = [];
this.options = {
container: "#gantt-chart",
showWeekends: true,
cellWidth: 21,
cellHeight: 31,
slideWidth: 1000,
vHeaderWidth: 200
};
}
// Save task after a resize or move
Gantt.prototype.saveTask = function(task) {
this.app.showLoadingIcon();
$.ajax({
cache: false,
url: $(this.options.container).data("save-url"),
contentType: "application/json",
type: "POST",
processData: false,
data: JSON.stringify(task),
complete: this.app.hideLoadingIcon.bind(this)
});
};
// Build the Gantt chart
Gantt.prototype.execute = function() {
this.data = this.prepareData($(this.options.container).data('tasks'));
var minDays = Math.floor((this.options.slideWidth / this.options.cellWidth) + 5);
var range = this.getDateRange(minDays);
var startDate = range[0];
var endDate = range[1];
var container = $(this.options.container);
var chart = jQuery("<div>", { "class": "ganttview" });
chart.append(this.renderVerticalHeader());
chart.append(this.renderSlider(startDate, endDate));
container.append(chart);
jQuery("div.ganttview-grid-row div.ganttview-grid-row-cell:last-child", container).addClass("last");
jQuery("div.ganttview-hzheader-days div.ganttview-hzheader-day:last-child", container).addClass("last");
jQuery("div.ganttview-hzheader-months div.ganttview-hzheader-month:last-child", container).addClass("last");
this.listenForBlockResize(startDate);
this.listenForBlockDrag(startDate);
};
// Render task list on the left
Gantt.prototype.renderVerticalHeader = function() {
var headerDiv = jQuery("<div>", { "class": "ganttview-vtheader" });
var itemDiv = jQuery("<div>", { "class": "ganttview-vtheader-item" });
var seriesDiv = jQuery("<div>", { "class": "ganttview-vtheader-series" });
for (var i = 0; i < this.data.length; i++) {
seriesDiv.append(jQuery("<div>", {
"class": "ganttview-vtheader-series-name tooltip",
"title": "<strong>" + this.data[i].column_title + "</strong> (" + this.data[i].progress + ")<br/>" + this.data[i].title
}).append(jQuery("<a>", {"href": this.data[i].link, "target": "_blank"}).append(this.data[i].title)));
}
itemDiv.append(seriesDiv);
headerDiv.append(itemDiv);
return headerDiv;
};
// Render right part of the chart (top header + grid + bars)
Gantt.prototype.renderSlider = function(startDate, endDate) {
var slideDiv = jQuery("<div>", {"class": "ganttview-slide-container"});
var dates = this.getDates(startDate, endDate);
slideDiv.append(this.renderHorizontalHeader(dates));
slideDiv.append(this.renderGrid(dates));
slideDiv.append(this.addBlockContainers());
this.addBlocks(slideDiv, startDate);
return slideDiv;
};
// Render top header (days)
Gantt.prototype.renderHorizontalHeader = function(dates) {
var headerDiv = jQuery("<div>", { "class": "ganttview-hzheader" });
var monthsDiv = jQuery("<div>", { "class": "ganttview-hzheader-months" });
var daysDiv = jQuery("<div>", { "class": "ganttview-hzheader-days" });
var totalW = 0;
for (var y in dates) {
for (var m in dates[y]) {
var w = dates[y][m].length * this.options.cellWidth;
totalW = totalW + w;
monthsDiv.append(jQuery("<div>", {
"class": "ganttview-hzheader-month",
"css": { "width": (w - 1) + "px" }
}).append($.datepicker.regional[$("body").data('js-lang')].monthNames[m] + " " + y));
for (var d in dates[y][m]) {
daysDiv.append(jQuery("<div>", { "class": "ganttview-hzheader-day" }).append(dates[y][m][d].getDate()));
}
}
}
monthsDiv.css("width", totalW + "px");
daysDiv.css("width", totalW + "px");
headerDiv.append(monthsDiv).append(daysDiv);
return headerDiv;
};
// Render grid
Gantt.prototype.renderGrid = function(dates) {
var gridDiv = jQuery("<div>", { "class": "ganttview-grid" });
var rowDiv = jQuery("<div>", { "class": "ganttview-grid-row" });
for (var y in dates) {
for (var m in dates[y]) {
for (var d in dates[y][m]) {
var cellDiv = jQuery("<div>", { "class": "ganttview-grid-row-cell" });
if (this.options.showWeekends && this.isWeekend(dates[y][m][d])) {
cellDiv.addClass("ganttview-weekend");
}
rowDiv.append(cellDiv);
}
}
}
var w = jQuery("div.ganttview-grid-row-cell", rowDiv).length * this.options.cellWidth;
rowDiv.css("width", w + "px");
gridDiv.css("width", w + "px");
for (var i = 0; i < this.data.length; i++) {
gridDiv.append(rowDiv.clone());
}
return gridDiv;
};
// Render bar containers
Gantt.prototype.addBlockContainers = function() {
var blocksDiv = jQuery("<div>", { "class": "ganttview-blocks" });
for (var i = 0; i < this.data.length; i++) {
blocksDiv.append(jQuery("<div>", { "class": "ganttview-block-container" }));
}
return blocksDiv;
};
// Render bars
Gantt.prototype.addBlocks = function(slider, start) {
var rows = jQuery("div.ganttview-blocks div.ganttview-block-container", slider);
var rowIdx = 0;
for (var i = 0; i < this.data.length; i++) {
var series = this.data[i];
var size = this.daysBetween(series.start, series.end) + 1;
var offset = this.daysBetween(start, series.start);
var text = jQuery("<div>", {"class": "ganttview-block-text"});
var block = jQuery("<div>", {
"class": "ganttview-block tooltip",
"title": this.getBarTooltip(this.data[i]),
"css": {
"width": ((size * this.options.cellWidth) - 9) + "px",
"margin-left": (offset * this.options.cellWidth) + "px"
}
}).append(text);
if (size >= 2) {
text.append(this.data[i].progress);
}
block.data("task", this.data[i]);
this.setTaskColor(block, this.data[i]);
jQuery(rows[rowIdx]).append(block);
rowIdx = rowIdx + 1;
}
};
// Get tooltip for task bars
Gantt.prototype.getBarTooltip = function(task) {
if (task.not_defined) {
return $(this.options.container).data("label-not-defined");
}
return "<strong>" + task.progress + "</strong><br/>" +
$(this.options.container).data("label-assignee") + " " + (task.assignee ? task.assignee : '') + "<br/>" +
$(this.options.container).data("label-start-date") + " " + $.datepicker.formatDate('yy-mm-dd', task.start) + "<br/>" +
$(this.options.container).data("label-end-date") + " " + $.datepicker.formatDate('yy-mm-dd', task.end);
};
// Set task color
Gantt.prototype.setTaskColor = function(block, task) {
if (task.not_defined) {
block.addClass("ganttview-block-not-defined");
}
else {
block.css("background-color", task.color.background);
block.css("border-color", task.color.border);
}
};
// Setup jquery-ui resizable
Gantt.prototype.listenForBlockResize = function(startDate) {
var self = this;
jQuery("div.ganttview-block", this.options.container).resizable({
grid: this.options.cellWidth,
handles: "e,w",
stop: function() {
var block = jQuery(this);
self.updateDataAndPosition(block, startDate);
self.saveTask(block.data("task"));
}
});
};
// Setup jquery-ui drag and drop
Gantt.prototype.listenForBlockDrag = function(startDate) {
var self = this;
jQuery("div.ganttview-block", this.options.container).draggable({
axis: "x",
grid: [this.options.cellWidth, this.options.cellWidth],
stop: function() {
var block = jQuery(this);
self.updateDataAndPosition(block, startDate);
self.saveTask(block.data("task"));
}
});
};
// Update the task data and the position on the chart
Gantt.prototype.updateDataAndPosition = function(block, startDate) {
var container = jQuery("div.ganttview-slide-container", this.options.container);
var scroll = container.scrollLeft();
var offset = block.offset().left - container.offset().left - 1 + scroll;
var task = block.data("task");
// Restore color for defined block
task.not_defined = false;
this.setTaskColor(block, task);
// Set new start date
var daysFromStart = Math.round(offset / this.options.cellWidth);
var newStart = this.addDays(this.cloneDate(startDate), daysFromStart);
task.start = newStart;
// Set new end date
var width = block.outerWidth();
var numberOfDays = Math.round(width / this.options.cellWidth) - 1;
task.end = this.addDays(this.cloneDate(newStart), numberOfDays);
if (numberOfDays > 0) {
jQuery("div.ganttview-block-text", block).text(task.progress);
}
// Update tooltip
block.attr("title", this.getBarTooltip(task));
block.data("task", task);
// Remove top and left properties to avoid incorrect block positioning,
// set position to relative to keep blocks relative to scrollbar when scrolling
block
.css("top", "")
.css("left", "")
.css("position", "relative")
.css("margin-left", offset + "px");
};
// Creates a 3 dimensional array [year][month][day] of every day
// between the given start and end dates
Gantt.prototype.getDates = function(start, end) {
var dates = [];
dates[start.getFullYear()] = [];
dates[start.getFullYear()][start.getMonth()] = [start];
var last = start;
while (this.compareDate(last, end) == -1) {
var next = this.addDays(this.cloneDate(last), 1);
if (! dates[next.getFullYear()]) {
dates[next.getFullYear()] = [];
}
if (! dates[next.getFullYear()][next.getMonth()]) {
dates[next.getFullYear()][next.getMonth()] = [];
}
dates[next.getFullYear()][next.getMonth()].push(next);
last = next;
}
return dates;
};
// Convert data to Date object
Gantt.prototype.prepareData = function(data) {
for (var i = 0; i < data.length; i++) {
var start = new Date(data[i].start[0], data[i].start[1] - 1, data[i].start[2], 0, 0, 0, 0);
data[i].start = start;
var end = new Date(data[i].end[0], data[i].end[1] - 1, data[i].end[2], 0, 0, 0, 0);
data[i].end = end;
}
return data;
};
// Get the start and end date from the data provided
Gantt.prototype.getDateRange = function(minDays) {
var minStart = new Date();
var maxEnd = new Date();
for (var i = 0; i < this.data.length; i++) {
var start = new Date();
start.setTime(Date.parse(this.data[i].start));
var end = new Date();
end.setTime(Date.parse(this.data[i].end));
if (i == 0) {
minStart = start;
maxEnd = end;
}
if (this.compareDate(minStart, start) == 1) {
minStart = start;
}
if (this.compareDate(maxEnd, end) == -1) {
maxEnd = end;
}
}
// Insure that the width of the chart is at least the slide width to avoid empty
// whitespace to the right of the grid
if (this.daysBetween(minStart, maxEnd) < minDays) {
maxEnd = this.addDays(this.cloneDate(minStart), minDays);
}
// Always start one day before the minStart
minStart.setDate(minStart.getDate() - 1);
return [minStart, maxEnd];
};
// Returns the number of day between 2 dates
Gantt.prototype.daysBetween = function(start, end) {
if (! start || ! end) {
return 0;
}
var count = 0, date = this.cloneDate(start);
while (this.compareDate(date, end) == -1) {
count = count + 1;
this.addDays(date, 1);
}
return count;
};
// Return true if it's the weekend
Gantt.prototype.isWeekend = function(date) {
return date.getDay() % 6 == 0;
};
// Clone Date object
Gantt.prototype.cloneDate = function(date) {
return new Date(date.getTime());
};
// Add days to a Date object
Gantt.prototype.addDays = function(date, value) {
date.setDate(date.getDate() + value * 1);
return date;
};
/**
* Compares the first date to the second date and returns an number indication of their relative values.
*
* -1 = date1 is lessthan date2
* 0 = values are equal
* 1 = date1 is greaterthan date2.
*/
Gantt.prototype.compareDate = function(date1, date2) {
if (isNaN(date1) || isNaN(date2)) {
throw new Error(date1 + " - " + date2);
} else if (date1 instanceof Date && date2 instanceof Date) {
return (date1 < date2) ? -1 : (date1 > date2) ? 1 : 0;
} else {
throw new TypeError(date1 + " - " + date2);
}
};

View File

@ -1,7 +1,8 @@
function LeadCycleTimeChart() {
function LeadCycleTimeChart(app) {
this.app = app;
}
LeadCycleTimeChart.prototype.execute = function(app) {
LeadCycleTimeChart.prototype.execute = function() {
var metrics = $("#chart").data("metrics");
var cycle = [$("#chart").data("label-cycle")];
var lead = [$("#chart").data("label-lead")];
@ -37,7 +38,7 @@ LeadCycleTimeChart.prototype.execute = function(app) {
},
y: {
tick: {
format: app.formatDuration
format: this.app.formatDuration
}
}
}

View File

@ -10,7 +10,8 @@ Router.prototype.dispatch = function(app) {
for (var id in this.routes) {
if (document.getElementById(id)) {
var controller = Object.create(this.routes[id].prototype);
controller.execute(app);
this.routes[id].apply(controller, [app]);
controller.execute();
break;
}
}
@ -30,5 +31,7 @@ jQuery(document).ready(function() {
router.addRoute('analytic-avg-time-column', AvgTimeColumnChart);
router.addRoute('analytic-task-time-column', TaskTimeColumnChart);
router.addRoute('analytic-lead-cycle-time', LeadCycleTimeChart);
router.addRoute('gantt-chart', Gantt);
router.dispatch(app);
app.listen();
});

View File

@ -1,7 +1,8 @@
function TaskTimeColumnChart() {
function TaskTimeColumnChart(app) {
this.app = app;
}
TaskTimeColumnChart.prototype.execute = function(app) {
TaskTimeColumnChart.prototype.execute = function() {
var metrics = $("#chart").data("metrics");
var plots = [$("#chart").data("label")];
var categories = [];
@ -28,7 +29,7 @@ TaskTimeColumnChart.prototype.execute = function(app) {
},
y: {
tick: {
format: app.formatDuration
format: this.app.formatDuration
}
}
},

View File

@ -8,6 +8,7 @@ Tooltip.prototype.listen = function() {
$(".tooltip").tooltip({
track: false,
show: false,
hide: false,
position: {
my: 'left-20 top',
at: 'center bottom+9',

File diff suppressed because one or more lines are too long

17
docs/gantt-chart.markdown Normal file
View File

@ -0,0 +1,17 @@
Gantt chart for projects
========================
The Gantt chart is available from the the action menu by project managers.
![Gantt Chart](http://kanboard.net/screenshots/documentation/gantt-chart-project.png)
- The **start date** and the **due date** of tasks are used to draw the chart
- Tasks can be resized and moved horizontally with your mouse
- There is no vertical drag and drop
- The bar is the same color as the task
- Each bar display a progression status in percentage, this percentage is calculated by using the column position on the board
- To fit with the Kanban model, tasks can be ordered by the board positions or by start date
- New tasks created from this view will be displayed on the board at the position 1 in the first column
- Tasks are displayed in black when there is no start or due date defined
![Task not defined](http://kanboard.net/screenshots/documentation/gantt-chart-not-defined.png)

View File

@ -27,6 +27,7 @@ Using Kanboard
- [Calendar](calendar.markdown)
- [Budget](budget.markdown)
- [Analytics](analytics.markdown)
- [Gantt chart](gantt-chart.markdown)
### Working with tasks

View File

@ -1,12 +1,12 @@
#!/bin/bash
print_css="print links table board task comment subtask markdown"
app_css="base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters"
app_css="base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters gantt"
vendor_css="jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min"
app_js="Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart BudgetChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart Router"
app_js="Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart BudgetChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart Router"
vendor_js="jquery-1.11.1.min jquery-ui.min jquery-ui-timepicker-addon.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min app.min"
lang_js="da de es fi fr hu it ja nl pl pt-br ru sv sr th tr zh-cn"
lang_js="da de es fi fr hu it ja nl pl pt pt-br ru sv sr th tr zh-cn"
function merge_css {

View File

@ -289,20 +289,21 @@ class TaskCreationTest extends Base
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_due' => $date)));
$this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_due' => $timestamp)));
$this->assertEquals(3, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_due' => '')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertNotFalse($task);
$this->assertEquals(1, $task['id']);
$this->assertEquals($date, date('Y-m-d', $task['date_due']));
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertNotFalse($task);
$this->assertEquals(2, $task['id']);
$this->assertEquals($timestamp, $task['date_due']);
$task = $tf->getById(3);
$this->assertEquals(3, $task['id']);
$this->assertEquals(0, $task['date_due']);
}
public function testDateStarted()
@ -336,6 +337,11 @@ class TaskCreationTest extends Base
$task = $tf->getById(4);
$this->assertEquals(time(), $task['date_started'], '', 1);
// Set empty string
$this->assertEquals(5, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => '')));
$task = $tf->getById(5);
$this->assertEquals(0, $task['date_started']);
}
public function testTime()