Blog personal

KANBAN – Gestión de tareas no planificadas

Cuando he tenido que gestionar proyectos he utilizado, de forma generalizada, Web2Project/DotProject y Mantis BT. De esta forma gestionaba las tareas planificadas y aquellas otras tareas o acciones que surgen en los proyectos (reuniones, seguimiento, etc.) que es necesario ejecutar pero que al ser, normalmente cortas en tiempo de ejecución, no se incluían en las tareas planificadas del proyecto.

Mantis BT es un producto muy utilizado y muy bueno, pero hasta este momento no cuenta con algo que ahora se utiliza mucho y que es un tablero Kanban.

Este ejemplo es un boceto de cómo podríamos hacer un aplicativo en PHPRunner que fuera ese sustituto de Mantis BT y que además tuviera un interfaz Kanban para la gestión y seguimiento de las tareas.

Técnicamente, este ejemplo es una aplicación PHPrunner , más o menos simple, a la cuál se ha icorporado 2 librerias javascript de GITHUB.

El interfaz del ejemplo ha quedado como:

El tablero Kanban se puede ver desde el punto de vista de las Tareas del usuario conectado (opción directa desde el menú) o las Tareas de un Proyecto, que se solicitará desde la consulta de los Proyectos.

El resultado es:

Es un único código para las dos visiones del tablero.

Los puntos referenciados indican:

(1) .- El título del kanban te da información de la visión del tablero.

(2) .- Haciendo clic en la “hamburguesa” podemos desplazar de una banda (estado) a otra.

(3) .- La información de la Tarea se puede personalizar. En este caso se utiliza la información de Id, nombre de tarea, código del proyecto, fecha de solicitud de inicio y estimación de tiempo para ejecutar la tarea, usuario al que se le ha asignado la tarea y situación de la tarea mediante codigo semafórico (control de riesgo).
En esta zona se puede hacer clic para que se abra una ventana popup con la información de la Tarea.

(4) .- Los estados (bandas) son configurables y también se define el color de los mismos.

(5).- Cuando se cambia una Tarea de un estado a otro se produce una petición asíncrona de actualización de la Base de Datos y utilizo la gestión de Notificación para informar del hecho y de su resultado.

Podéis probar el resultado en https://fhumanes.com/runner2kanban/, con la identificación de login “admin” y password “admin”.

Los códigos más interesantes son:

– Código para hacer la representación del tablero.

kanban.php
 
<?php
$url = $_SESSION['config'][array_search('URL', array_column($_SESSION['config'], 'name'))][value];
$kanban_path = $_SESSION['config'][array_search('KANBAN_PATH', array_column($_SESSION['config'], 'name'))][value];
$kanban_icon = $_SESSION['config'][array_search('KANBAN_ICON', array_column($_SESSION['config'], 'name'))][value];
$kanban_ajax = $_SESSION['config'][array_search('KANBAN_AJAX', array_column($_SESSION['config'], 'name'))][value];
$notify = $_SESSION['config'][array_search('NOTIFY', array_column($_SESSION['config'], 'name'))][value];
$language = $_SESSION['language'];
$kanban_option  = $_SESSION['kanban_option'];
//  Load lanes 
global $conn;
$sql="SELECT `idlanes`, `name`, `color`, `orden`, `is_end` FROM lanes ORDER BY `orden`";
$resql=db_query($sql,$conn);
$data_lane=$resql->fetch_all(MYSQLI_ASSOC);
// load Task
If ($kanban_option == 'project') {
    $where = "t.`projects_project_id` =".$_SESSION['project_id'];
} else {
    $where = "t.`owner` = ".$_SESSION['user_id'];
}
$sql="
SELECT
t.`task_id`,
p.`short_name`,
t.`name`,
t.`start_date`,
t.`duration`,
catalog_code,
t.`status`,
t.`situation`,
u.`login`
FROM tasks t
join projects p on (t.`projects_project_id` = p.`project_id`)
join ( SELECT c.catalog_num, c.catalog_code FROM `catalog` AS c INNER JOIN super_catalog AS s ON c.super_catalog_idsuper_catalog = s.idsuper_catalog
       where super_code = 'DURATION_TYPE' AND LANGUAGE = '$language' ) d on (t.`duration_type` = d.catalog_num)
join users u on (t.`owner` = u.`user_id`)
where $where
order by t.`status`, t.`start_date`, t.`task_id`  ";
$resql=db_query($sql,$conn);
$data_task=$resql->fetch_all(MYSQLI_ASSOC);
$style_lane = '';
foreach ($data_lane as $lane) {
// .success {background: #00b961;}
    $style_lane .= ".".$lane[name]."{ background:".$lane[color]."}";
}
$display = "
    <link rel=\"stylesheet\" href=\"$kanban_path/jkanban.css\" />
    <style>
      #myKanban {
        overflow-x: auto;
        padding: 5px 0; /* 10px */
      }";
$display .= $style_lane;
$display .=
"  </style>
  </head>
  <body>
    <div id=\"myKanban\"></div>
    <script src=\"$kanban_path/jkanban.js\"></script>
    <script src=\"$notify/notify.js\"></script>
    <script>
    var HttpClient = function() {
        this.get = function(aUrl, aCallback) {
            var anHttpRequest = new XMLHttpRequest();
            anHttpRequest.onreadystatechange = function() { 
                if (anHttpRequest.readyState == 4 && anHttpRequest.status == 200)
                    aCallback(anHttpRequest.responseText);
            }
            anHttpRequest.open( \"GET\", aUrl, true );            
            anHttpRequest.send( null );
        }
    }
      // -----------------------------------------------------------------------------------------------------
      var site ='$kanban_ajax?'; // Para informar del cambio de Lane
      var lane = ''; // Variable para saber la Lane que recibe el objeto
      var task = ''; // Variable para saber la tarea que se cambia
      
      // -----------------------------------------------------------------------------------------------------
        function onShowNotification () {
            // console.log('notification is shown!');
        }
        function onCloseNotification () {
            // console.log('notification is closed!');
        }
        function onClickNotification () {
            // console.log('notification was clicked!');
        }
        function onErrorNotification () {
            console.error('Error showing notification. You may need to request permission.');
        }
        function onPermissionGranted () {
            console.log('Permission has been granted by the user');
            doNotification();
        }
        function onPermissionDenied () {
            console.warn('Permission has been denied by the user');
        }
        function doNotification (response='') {
         if (!Notify.needsPermission) {   
            var myNotification = new Notify('PHPRuner KANBAN', {
                body: 'La tarea ('+task+') ha cambiado al estado: '+lane+' Respuesta: '+response,
                tag: task,
                notifyShow: onShowNotification,
                notifyClose: onCloseNotification,
                notifyClick: onClickNotification,
                notifyError: onErrorNotification,
                timeout: 6
            });
            myNotification.show();
            }  else if (Notify.isSupported()) {
                Notify.requestPermission(onPermissionGranted, onPermissionDenied);
            }
        }
      // -----------------------------------------------------------------------------------------------------
      var KanbanTest = new jKanban({
        element: \"#myKanban\",
        gutter: \"10px\",
        widthBoard: \"350px\",
        itemHandleOptions:{
          enabled: true,
        },
        click: function(el) {
          console.log(\"Trigger on all items click!\");
          console.log(el);
        },
        dropEl: function(el, target, source, sibling){
          console.log('dropEL');
          console.log(target.parentElement.getAttribute('data-id'));
          lane = target.parentElement.getAttribute('data-id');
          console.log(el, target, source, sibling)
        },
        buttonClick: function(el, boardId) {
          console.log('buttonClick');
          console.log(el);
          console.log(boardId);
          // create a form to enter element
          var formItem = document.createElement(\"form\");
          formItem.setAttribute(\"class\", \"itemform\");
          formItem.innerHTML =
            '<div class=\"form-group\"><textarea class=\"form-control\" rows=\"2\" autofocus></textarea></div><div class=\"form-group\"><button type=\"submit\" class=\"btn btn-primary btn-xs pull-right\">Submit</button><button type=\"button\" id=\"CancelBtn\" class=\"btn btn-default btn-xs pull-right\">Cancel</button></div>';
          KanbanTest.addForm(boardId, formItem);
          formItem.addEventListener(\"submit\", function(e) {
            e.preventDefault();
            var text = e.target[0].value;
            KanbanTest.addElement(boardId, {
              title: text
            });
            formItem.parentNode.removeChild(formItem);
          });
          document.getElementById(\"CancelBtn\").onclick = function() {
            formItem.parentNode.removeChild(formItem);
          };
        },
        addItemButton: false,
        boards: [";
for ($i = 0; $i < count($data_lane); $i++) {
    $lane = $data_lane[$i];
    $lane_pos = $data_lane[$i+1];
    $lane_min = $data_lane[$i-1];
    $display .= "{ id: \"".$lane[name]."\", title: \"".$lane[name]."\", class: \"".$lane[name]."\",  item: [ \n  ";
    $display_task = 0;
    for ($k = 0; $k < count($data_task); $k++) {
        $task = $data_task[$k];
        $task[name]= addslashes($task[name]); // save " in Name
        if ($task[status] === $lane[idlanes]) {
            $display_task = 1;
            // Situation = semaphore
            $img_situation = '';
            switch ($task[situation]) {
            // <img src=\\\"$kanban_icon/red.png\\\"> <img src=\\\"$kanban_icon/lock.png\\\">
               case 0:
                     $img_situation = " <img src=\\\"$kanban_icon/green.png\\\">";
                     break;
               case 1:
                     $img_situation = " <img src=\\\"$kanban_icon/orange.png\\\">";
                     break;
               case 2:
                     $img_situation = " <img src=\\\"$kanban_icon/red.png\\\">";
                     break;
               case 3:
                     $img_situation = "  <img src=\\\"$kanban_icon/red.png\\\"> <img src=\\\"$kanban_icon/lock.png\\\">";
                     break;
               }
      $display .= "{ id: \"".$task[task_id]."\", title: \"".
      "<img src=\\\"$kanban_icon/id.png\\\">".$task[task_id]."<img src=\\\"$kanban_icon/title.png\\\">".$task[name].
      "<BR><img src=\\\"$kanban_icon/project.png\\\">".$task[short_name]."<img src=\\\"$kanban_icon/date.png\\\">".substr($task[start_date], 0, -8).", ".$task[duration]." ".$task[catalog_code]].
      "<BR><img src=\\\"$kanban_icon/user.png\\\">".$task[login].$img_situation."\",\n";
      $display .= "
           click: function(el) {
               task = el.dataset.eid;
               var url = \"kanban_view.php?editid1=\"+task;
               var header = 'Task: '+ task ;
           window.popup = Runner.displayPopup({url : url,
               width: 900,
               height: 550,
               header: header
               });
      },
           drag: function(el, source) {
               console.log(\"START DRAG: \" + el.dataset.eid);
      },
           dragend: function(el) {
               task = el.dataset.eid;
               console.log(\"END DRAG: \" + task);                
               console.log(lane);
               // Solicitud de actuaización de cambio de tarea  
               var client = new HttpClient();
               client.get(site+'id='+task+'&lane='+lane, function(response) {
                    console.log('Respuesta: '+response);
                    doNotification(response);
                });    
       },
           drop: function(el) {
                console.log(\"DROPPED: \" + el.dataset.eid);
                }";
     $display .= "\n },";
      }
    }
    if ($display_task === 1 ){ 
       $display_task = 0;
       $display = substr($display, 0, -1); // Quitar la última ","                
     }
    $display .= " ] }\n ,";
}
// $display .= "}\n,";
$display = substr($display, 0, -1); // Quitar la última "," 
$display .= " ]} ); </script>";
$value = $display;
?>

– Código de la actualización asíncrona

kanban_ajax.php
 
<?php
@ini_set("display_errors","1");
@ini_set("display_startup_errors","1");
require_once("include/dbcommon.php");
header("Expires: Thu, 01 Jan 1970 00:00:01 GMT"); 
$query = $_SERVER['QUERY_STRING'];
$parameters = explode( '&', $query);
$parameter = explode( '=', $parameters[0]);
if ($parameter[0] == 'id') {
    $task_id = $parameter[1];
} else {
    echo "error parameter(1)!!!!!!". $query;
    die();
}
$parameter = explode( '=', $parameters[1]);
if ($parameter[0] == 'lane') {
    $lane_name = $parameter[1];
} else {
    echo "error parameter(2)!!!!!!" . $query;
    die();
}
// Update task for lane
global $conn;
$sql="SELECT `idlanes`, `is_end` FROM lanes where `name` = '$lane_name'";
$resql=db_query($sql,$conn);
if ($row = db_fetch_array($resql)) {
    $lane_id = $row[idlanes];
    $is_end = $row[is_end];
    if ( $is_end == 0 ) {
        $sql_2 ="update tasks set `status` = $lane_id, end_date = null  where `task_id` = $task_id";
       } else {
        $sql_2 ="update tasks set `status` = $lane_id, end_date = now()  where `task_id` = $task_id";
     }
     $resql_2=db_query($sql_2,$conn);
     echo "OK";
      } else {
        echo "error parameter(3)!!!!!!" . $lane_name ." not exist";
        die();
      }
?>

También os dejo los ficheros del proyecto para que lo podáis desplegar y adaptar a vuestras necesidades.

Esto no es una aplicación. Es un conjunto de ideas y código para desarrollar tu solución.

Como siempre, para cualquier duda o lo que necesitéis, indicádmelo a través de email [email protected].