Gestor de proyectos (actualizado 21/04/2026)

Gestor de proyectos en PHPrunner vs. DotProject / Web2Project

Es muy habitual requerir una gestión de proyectos (proyectos y tareas) en muchas actividades de un empresa.

La primera impresión puede ser que se requiera para la gestión de los proyectos informáticos, pero no sólo es para eso, sino que también se requiere para el control de Planes (sistemas, estratégicos, etc.) que normalmente tienen Medidas y que esta Medidas tienen Proyectos de ejecución.

También, otros pretenden que sean herramientas que sustituye a herramientas como MS Project. Esto no es así, estas herramientas sirven para coordinar y controlar muchos proyectos, pero no el detalle de los proyectos.

Cuando he utilizado soluciones de este tipo (DotProject  y Web2Project, productos open source desarrollados en PHP y MySQL) siempre se complementaba los detalles de los proyectos con su seguimiento detallado en MS Project.

También, cuando he trabajado en la ejecución y seguimiento de los planes, una parte importante era el seguimiento de los proyectos, pero otras muy importantes eran:

  • Inventario de las Medidas/Acciones con todos sus atributos de agrupaciones.
  • Informes y cuadros de mando de seguimiento.
  • Resumen de seguimiento para la dirección de la empresa.
  • Etc.

Con esto quiero señalar que si utilizaba, por ejemplo Web2Project, tenía que integrar sus datos con otro aplicativo que manejase el resto de los datos y todos los informes y cuadros de mandos.

Lo que he pretendido es disponer de una solución de gestión de proyectos que pudiera crecer y adecuarse el resto de los datos que se requieren en los Planes y esto es bastante sencillo utilizando PHPRunner, por lo que se requiere es la funcionalidad básica de la gestión de los proyectos, a saber:

  • Las entidades básicas son Compañías, Departamentos, Proyectos y Tareas.
  • Las tareas se planifican según calendario laboral (sin fines de semana y festivos) o días naturales.
  • En las tareas debe poderse definir jerarquía entre ellas (agrupación de tareas).
  • Entre las tareas se pueden definir dependencias y poderse recalcular la fecha de inicio sí la tarea de la que depende se retrasa (camino crítico).

Otro aspecto muy importante es la seguridad, de tal forma que:

  • A los proyectos se podrán configurar los acceso según:
    • Público (todos los usuarios podrán acceder)
    • Compañía (sólo podrán acceder los usuarios de la compañía del proyecto).
    • Departamento (sólo podrán acceder los usuarios del departamento del proyecto)
    • Propietario (sólo podrá acceder el propietario del proyecto)
    • Propietario proyecto y propietarios de tareas (Sólo podrán acceder estos usuarios)
  • A las tareas se podrán configurar los acceso según:
    • Público (todos los usuarios podrán acceder)
    • Compañía (sólo podrán acceder los usuarios de la compañía del proyecto).
    • Departamento (sólo podrán acceder los usuarios del departamento del proyecto)
    • Propietario (sólo podrá acceder el propietario del proyecto)
    • Propietario proyecto y propietarios de tareas (Sólo podrán acceder estos usuarios)

Esta gestión de seguridad se ha realizado definiendo una lógica diferente a la utilizada normalmente en los desarrollos de PHPrunner y complementaria a estos.

Para el apoyo visual de la información se ha incluido la librería de javascript JSGANTT y FULLCALENDAR

Se han utilizado los plugins (descargables gratuitamente desde este portal):

  • Colors.- Para selección y visualización de colores.
  • BootstrapDataPicker.- Calendario para días festivos

DEMO: https://fhumanes.com/project

El ejemplo se ha desarrollado en inglés y español y se ha partido del modelo de datos de web2Project, para la definición de los atributos de “projects” y “tasks”.

También hay definición de CSS para disponer de un interfaz de aplicación agradable.

Esta figura muestra la página principal del ejercicio.

Además de arreglar algún mal funcionamiento, la nueva versión se ha actualizado a PHPRunner 10.6, se han corregido algunos errores de funcionamiento y se ha añadido:

  • La gestión y visualización de problemas. El desarrollador informa del avance de los trabajos y puede informar de un problema y explicar el mismo, para que este dato llegue a todos los usuarios del proyecto.
  • Se ha añadido la gestión de días festivos y se tiene en cuenta en la planificación de las actividades.
  • Se ha creado un apartado de Informes en donde se ha hecho estos 2 casos:

Consulta de Tareas:

(1) Pestaña de todas las tareas.
(2) Pestaña de las tareas en que el usuario conectado es el propietario
(3) Pestaña de las tareas en que el usuario conectado está asignado.
(4) Se pueden aplicar diferentes filtros para restringir las tareas que salen en las distintas pestañas
(5) Se pueden buscar las tareas asignadas a un usuario.

Consulta de Usuarios asignados (Recursos);

(1) Se pueden utilizar los filtros para conocer las tareas asignadas a los usuarios.
(2) Se muestran las tareas, según asignación, mostrando el porcentaje de asignación.
(3) se puede hacer clic en el segmento y visualizar todos los datos de la tarea.

 

 

Los usuarios de acceso son los que se muestran en la imagen con sus password.

MUY IMPORTANTE:

No es una aplicación es una base para desarrollar tú aplicación

Ampliación de la gestión de seguridad

He cambiado la gestión de seguridad, haciendo qué:

  • Todos los datos de Proyectos y Tareas sean consultables para todos los usuarios que tengan acceso al sistema.
  • Sólo podrán modificar Proyectos (1) o Tareas (3), los usuarios que se ajusten a la configuración de acceso que tengan estas informaciones.
  • Las Tareas y ficheros (2) heredaran de Proyectos (1) los accesos cuando los estemos tratando.
  • De igual forma, los Log (4) y Ficheros (4) heredaran los accesos de Tareas (3) cuando los estemos tratando.
  • Para agilizar el interfaz, de ha cambiado la lógica de PHPRunner para que las Altas/Inserciones de Log y Ficheros se hagan de forma directa en «POPUP».

Codificación de elementos más importantes

Este conjunto de elementos son las características más importante del desarrollo, por ello voy a exponerlas en el artículo.

gantt_project_jsgantt.php
<?php
$keyMaster= $_SESSION['project_id'];

if ($_SESSION['language'] == "Spanish") {
$language=<<<EOT
// Language
g.addLang('es',{
  'january': 'Enero',
  'february': 'Febrero',
  'march': 'Marzo',
  'april': 'Abril',
  'maylong': 'Mayo',
  'june': 'Junio',
  'july': 'Julio',
  'august': 'Agosto',
  'september': 'Septiembre',
  'october':'Octubre',
  'november':'Noviembre',
  'december':'Diciembre',
  'jan':'Ene',
  'feb':'Feb',
  'mar':'Mar',
  'apr':'Abr',
  'may':'May',
  'jun':'Jun',
  'jul':'Jul',
  'aug':'Ago',
  'sep':'Sep',
  'oct':'Oct',
  'nov':'Nov',
  'dec':'Dic',

  'sunday':'Domingo',
  'monday':'Lunes',
  'tuesday':'Martes',
  'wednesday':'Miércoles',
  'thursday':'Jueves',
  'friday':'Viernes',
  'saturday':'Sábado',
  'sun':'	Dom',
  'mon':'	Lun',
  'tue':'	Mar',
  'wed':'	Mie',
  'thu':'	Jue',
  'fri':'	Vie',
  'sat':'	Sab',
  'resource':'Recurso',
  'duration':'Duración',
  'comp':'% Compl.',
  'completion':'Terminado',
  'startdate':'Inicio',
  'enddate':'Fin',
  'moreinfo':'+información',
  'notes':'Notas',

  'format':'Formato',
  'hour':'Hora',
  'day':'Dia',
  'week':'Semana',
  'month':'Mes',
  'quarter':'Trimestre',
  'hours':'Horas',
  'days':'Días',
  'weeks':'Semanas',
  'months':'Meses',
  'quarters':'Trimestres',
  'hr':'Hr',
  'dy':'D',
  'wk':'Sem',
  'mth':'Mes',
  'qtr':'Trim',
  'hrs':'Hrs',
  'dys':'Dias',
  'wks':'Sems',
  'mths':'Meses',
  'qtrs':'Trims'});

g.setLang('es');
EOT;
} else {
$language='';	
}

if ($_SESSION['language'] == "Spanish") {
  $options=<<<EOT
  g.setDateTaskTableDisplayFormat('dd/mm/yyyy'); // Set format to display dates ('mm/dd/yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd')
  g.setDateTaskDisplayFormat('day dd month yyyy'); // Shown in tool tip box
  g.setDayMajorDateDisplayFormat('mon yyyy - Week ww') // Set format to display dates in the "Major" header of the "Day" view
  g.setWeekMinorDateDisplayFormat('dd mon') 	// Set format to display dates in the "Minor" header of the "Week" view
EOT;
} else {
  $options=<<<EOT
  g.setDateTaskTableDisplayFormat('mm.dd.yyyy'); // Set format to display dates ('mm/dd/yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd')
  // g.setDateTaskDisplayFormat('day dd month yyyy'); // Shown in tool tip box
  // g.setDayMajorDateDisplayFormat('yyyy mon- Week ww') // Set format to display dates in the "Major" header of the "Day" view
  // g.setWeekMinorDateDisplayFormat('mon dd') // Set format to display dates in the "Minor" header of the "Week" view
EOT;
}

$records ='';
// Read Task the Project
$sql="SELECT
      t.`task_id`,
      t.`name`,
      DATE_FORMAT(t.`start_date`,'%Y-%m-%d') `start_date`,
      DATE_FORMAT(t.`end_date`,'%Y-%m-%d') `end_date`,
      t.`dynamic`,
      t.`related_url`,
      t.`milestone`,
      t.`percent_complete`,
      ifnull(t.`parent`,0) parent,
      ifnull(t.`dependent`,0) dependent,
      t.`description`,
      ifnull(u.login,'') creator
    FROM tasks t
      inner join users u on (t.creator=u.user_id)
    WHERE t.`projects_project_id` = $keyMaster
    -- ORDER BY t.`order`";
$resql = DB::Query($sql);

 while ($row = $resql->fetchAssoc()) {
    $task_id = $row['task_id'];
    $task_name = addslashes($row['name']);
    $task_start_date = $row['start_date'];
    $task_end_date = $row['end_date'];
    $task_color = 'gtaskblue';
    if ($row['dynamic'] == 1){ $task_color = 'ggroupblack'; }
    $task_link = $row['related_url'];
    $task_mile = $row['milestone'];
    $task_resource = $row['creator'];
    $task_completion= $row['percent_complete'];
    $task_group = $row['dynamic'];
    $task_parent = $row['parent'];
    $task_open = '0';
    $task_depend = $row['dependent'];
    $task_caption = '';
    $task_description = $row['description'];
    $task_note = addslashes( 
            str_replace(hex2bin('0a'),'',
              str_replace(hex2bin('0d'),'',
                str_replace(PHP_EOL,'<br />', $task_description)
                )
              )
            );

    $records.="g.AddTaskItem(new JSGantt.TaskItem(";
    $records.="$task_id,'$task_name','$task_start_date','$task_end_date','$task_color','$task_link',$task_mile,'$task_resource',$task_completion,$task_group,$task_parent,$task_open,'$task_depend','$task_caption','$task_note',g));";
    }

// Read Project the Project
$sql="SELECT (999000000+p.project_id) task_id,
       concat(p.short_name,' - ',p.name) name,
      DATE_FORMAT(p.`start_date`,'%Y-%m-%d') `start_date`,
      DATE_FORMAT(ifnull(p.`actual_end_date`,p.`end_date`),'%Y-%m-%d') `end_date`,
      0  `dynamic`,
      '' `related_url`,
      0  `milestone`,
      p.`percent_complete`,
      0   parent,
      0   dependent,
      p .`description`,
      ifnull(u.login,'') creator
FROM projects p
  inner join users u on (p.owner=u.user_id)
WHERE p.project_parent = $keyMaster";
$resql = DB::Query($sql);

 while ($row = $resql->fetchAssoc()) {
    $task_id = $row['task_id'];
    $task_name = addslashes($row['name']);
    $task_start_date = $row['start_date'];
    $task_end_date = $row['end_date'];
    $task_color = 'gtaskpurple';
    if ($row['dynamic'] == 1){ $task_color = 'ggroupblack'; }
    $task_link = $row['related_url'];
    $task_mile = $row['milestone'];
    $task_resource = $row['creator'];
    $task_completion= $row['percent_complete'];
    $task_group = $row['dynamic'];
    $task_parent = $row['parent'];
    $task_open = '0';
    $task_depend = $row['dependent'];
    $task_caption = '';
    $task_description = $row['description'];
    $task_note = addslashes( 
            str_replace(hex2bin('0a'),'',
              str_replace(hex2bin('0d'),'',
                str_replace(PHP_EOL,'<br />', $task_description)
                )
              )
            );

    $records.="g.AddTaskItem(new JSGantt.TaskItem(";
    $records.="$task_id,'$task_name','$task_start_date','$task_end_date','$task_color','$task_link',$task_mile,'$task_resource',$task_completion,$task_group,$task_parent,$task_open,'$task_depend','$task_caption','$task_note',g));";
    }

$html = <<<EOT
<link rel="stylesheet" type="text/css" href="jsgantt/jsgantt.css" />
<script language="javascript" src="jsgantt/jsgantt.js"></script>

<div style="position:relative" class="gantt" id="GanttChartDIV"></div>

<script>
var g = new JSGantt.GanttChart(document.getElementById('GanttChartDIV'), 'week');
if (g.getDivId() != null) {
  g.setCaptionType('Complete');  				// Set to Show Caption (None,Caption,Resource,Duration,Complete)
  g.setQuarterColWidth(36);
  g.setShowDur(0); 							// Show/Hide Duration (0/1)
  g.setShowComp(0); 							// Show/Hide % Compl. (0/1)
  g.setDateInputFormat('yyyy-mm-dd');  		// Set format of input dates ('mm/dd/yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd')
$options
  g.setShowTaskInfoLink(1); 					// Show link in tool tip (0/1)
  g.setShowEndWeekDate(0); 					// Show/Hide the date for the last day of the week in header for daily view (1/0)
  g.setUseSingleCell(10000); 					// Set the threshold at which we will only use one cell per table row (0 disables).  Helps with rendering performance for large charts.
  g.setFormatArr('Day', 'Week', 'Month', 'Quarter'); // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers

$language
// Parameters(pID, pName,pStart,pEnd,pStyle,pLink (unused), pMile, pRes, pComp, pGroup, pParent, pOpen, pDepend, pCaption, pNotes, pGantt)

$records
  g.Draw();	
  g.DrawDependencies();
} else {
  alert("not defined");
}

</script>
EOT;

echo $html;

?>
timeline.php
<?php

if ($_SESSION['language'] == 'Spanish' ) {
  $language = 'es';
  $firstDay = 1;
  $Tusers = 'Usuarios';
} else {
  $language = 'en';
  $firstDay = 0;
  $Tusers = 'Users';
}

$users =

$now = substr(now(),0,10);

$str1 = <<<EOD
 <style>
    html, body {
      margin: 0;
      padding: 0;
      font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
      font-size: 12px; 
    }
    #calendar {
      width: 100%;
      height: 90%;
      margin: 4px auto;
    }
  </style>


<link href='fullcalendar/packages/core/main.css' rel='stylesheet' />
<link href='fullcalendar/packages-premium/timeline/main.css' rel='stylesheet' />
<link href='fullcalendar/packages-premium/resource-timeline/main.css' rel='stylesheet' />
<script src='fullcalendar/packages/core/main.js'></script>
<script src='fullcalendar/packages/interaction/main.js'></script>
<script src='fullcalendar/packages-premium/timeline/main.js'></script>
<script src='fullcalendar/packages-premium/resource-common/main.js'></script>
<script src='fullcalendar/packages-premium/resource-timeline/main.js'></script>

<script src='fullcalendar/packages/core/locales-all.js'></script>
<script src='fullcalendar/tooltip/tooltip.min.js'></script>

<script>
  var initialLocaleCode = '$language';
  var firstDay = '$firstDay'; 
  
  document.addEventListener('DOMContentLoaded', function() {
    var calendarEl = document.getElementById('calendar');

    var calendar = new FullCalendar.Calendar(calendarEl, {
      plugins: [ 'interaction', 'resourceTimeline' ],
      now: '$now',
      
      // timeZone: 'UTC+1', // the default 'local' (unnecessary to specify)
      locale: initialLocaleCode,
      businessHours: {
      // days of week. an array of zero-based day of week integers (0=Sunday)
      daysOfWeek: [ 1, 2, 3, 4, 5 ], // Monday - Thursday
      startTime: '08:00', // a start time (08am in this example)
      endTime: '18:00', // an end time (6pm in this example)
        },
      
      editable: false,
      aspectRatio: 1.8,
      scrollTime: '00:00',
      header: {
        left: 'today prev,next',
        center: 'title',
        right: 'resourceTimelineMonth,resourceTimelineYear'
      },
      defaultView: 'resourceTimelineMonth',
      navLinks: true,
      resourceAreaWidth: '25%',
      resourceLabelText: '$Tusers',
      //
      eventClick: function(info) {
        var Ev_id = info.event.id;
        var title = info.event.title;
        var url = "v_resource_view.php?editid1="+Ev_id;
        var header = '<h2 data-itemtype="view_header" data-itemid="view_header" data-pageid="10">'+'Resource: '+ title+'</h2>' ;
        window.popup = Runner.displayPopup({
                    url: url,
                    width: 1000,
                    height: 600,
                    header: header
       }); 
      },
      //
      resourceLabelText: '$Tusers',
      resources: { // you can also specify a plain string like 'json/resources.json'
        url: 'timeline_ajax_resource.php'},

      events: { // you can also specify a plain string like 'json/events-for-resources.json'
        url: 'timeline_ajax_event.php'},
    });

    calendar.render();
  });

</script>
  <div id='calendar'></div>

EOD;

echo $str1;

?>
timeline_ajax_resource.php
<?php
require_once("include/dbcommon.php"); // DataBase PHPRunner

// Require our Event class and datetime utilities
require 'fullcalendar/php/utils.php';



$sql = "
SELECT u.user_id, u.login, u.username , concat(c.name,'+', ifnull(d.name,'')) name, ((u.companies_company_id*10000) + ifnull(u.departments_dept_id,0)) id_companie
FROM users u
left join companies c on (u.companies_company_id = c.company_id)
left join departments d On (d.dept_id = u.departments_dept_id)
order by c.name, d.name,u.login
";


$resql = DB::Query($sql); 
// Accumulate an output array of event data arrays.
// $output_arrays = array();
$str = '[';

$group = '';
while ($data = $resql->fetchAssoc()){
 
    If ($group <> $data['name']) {
        if ($group <> '' ) {
            $str = substr($str,0,strlen($str)-1); // erase last ","
            $str .= '  ] },';
        }
        $str .= '{ "id": "'.$data['id_companie'].'", "title": "'.$data['name'].'", "children": [';
        $group = $data['name'];
        
    } 
    $str .= '{ "id": "'.$data['user_id'].'", "title": "'.$data['username'].'", "eventColor": "'.'#1D52B2'.'" },';
    
}
$str = substr($str,0,strlen($str)-1); // erase last ","
$str .= '  ] } ]';

echo $str;
// Send JSON to the client.
// $str = json_encode($output_arrays);
// echo json_encode($output_arrays);

?>
timeline_ajax_event.php
<?php
require_once("include/dbcommon.php"); // DataBase PHPRunner

// Require our Event class and datetime utilities
require 'fullcalendar/php/utils.php';

// Short-circuit if the client did not give us a date range.
if (!isset($_GET['start']) || !isset($_GET['end'])) {
  die("Please provide a date range.");
}

// Parse the start/end parameters.
// These are assumed to be ISO8601 strings with no time nor timeZone, like "2013-12-29".
// Since no timeZone will be present, they will parsed as UTC.
$range_start = parseDateTime($_GET['start']);
$range_end = parseDateTime($_GET['end']);

// Parse the timeZone parameter if it is present.

$time_zone ='UTC';
if (isset($_GET['timeZone'])) {
//   $time_zone = new DateTimeZone($_GET['timeZone']);
}


$start = substr($_GET['start'],0,10).' 00:00:00'; 
$end   = substr($_GET['end']  ,0,10).' 00:00:00';
$now   = date('Y-m-d H:i:s', strtotime(now(). ' +2 hour')); // now() + 1
if ( $start < $now ) { //  Minimum date of the moment
//		$start = $now;
    }
$language = $_SESSION['language'];

$strSQL = $_SESSION['timeline_sql'];
$strWhereClause = $_SESSION['timeline_where'];

$where = ' AND ';
if ($strWhereClause == '') {
    $where = ' WHERE ';
}

$sql = $strSQL . $where. " t1.end_date >= '$start' and t1.start_date <= '$end' ";

custom_error(4,"SQL de Eventos Timeline : ".$sql); // To debug  

$resql = DB::Query($sql); 
// Accumulate an output array of event data arrays.
$output_arrays = array();

while ($data = $resql->fetchAssoc()){  
$e_start = substr($data['start_date'],0,10); 
$e_end   = substr($data['end_date'],0,10).' 00:00:00';
$output_arrays[] = array(
    'id'=>$data['user_task_id'],
    'title'=>$data['perc_assignment'].' %'.' - '.$data['login'],
    'resourceId'=>$data['t3_users_user_id'],
    'start'=>$e_start,
    'end'=>date('Y-m-d', strtotime($e_end. ' +1 day')),
    'description'=>$data['username']);
    }

// Send JSON to the client.
// $str = json_encode($output_arrays);
echo json_encode($output_arrays);

?>
Actualización del 31/01/2023

En esta actualización, además de utilizar la versión 9.7 y así poder utilizar PHP 8.1, incorpora:

  • La nueva versión del plugin BootstrapDataPicker.- Calendario con días festivos
  • La jerarquía de los Proyectos. Un proyecto puede tener proyectos «hijos» y esto es porque los proyectos se puede subdividir en subproyectos para respetar la dependencia de Compañía y/o Departamentos. En una empresa grande o mediana, es muy habitual que en los proyectos se encarguen actividades a otros Departamentos y dichas actividades dependan de un responsable de proyecto diferente, pero que a su vez reporte al Director del Proyecto «principal».
  • El modelo de datos no ha cambiado con estos cambios.

El aspecto del Gantt ha cambiado al incorporar  los subproyectos.


Actualización del 21/04/2026

Creo que este es el producto que más éxito ha tenido de mi blog.

Como estaba un poco antiguo y el Grantt era de una solución que no ha sido actualizada en los últimos años, consideré que debería actualizarlo y para ello he seleccionado la solución de Gantt de AnyChart. Como he indicado en otros artículos, la solución de AnyChart es de una altísima calidad y con la licencia del producto que incluye PHPRunner, te facilita el uso de todos sus productos.

Además del cambio de la solución de Gantt, he incluido estas otras funcionalidades.

  • Poder visualizar y editar todas las características de las tareas desde el mismo Gantt.
  • He corregido un error en el calculo de la fecha final partiendo de los días de la tarea. El problema era la edición del formato de fecha para español e inglés.
  • He incluido la funcionalidad de Backup y Restore de la base de datos, desde la misma aplicación. Esto me facilitará la restauración de los datos iniciales, ya que al final los usuarios «destruyen» el juego de pruebas con que lo instalo.
  • He pasado y dejado la versión en la versión 10.91 de PHPRunner. Desde ella, podría ser migrada a versiones 11 del producto.
  • También, voy a dejar una versión generada, para que quién no tenga PHPRunner pueda instalarlo y configurarlo, apoyándose en el fichero «config.php» que tiene los datos de conexión a la Base de Datos. He tenido varios usuarios que estaban interesados en la solución, pero no contaban con PHPRunner.

El código que genera el Gantt y que define los eventos del mismo es:

gantt_project_anychart.php
<?php
$keyMaster= $_SESSION['project_id'];
$html = <<<EOT
<script src="anychart_8.13.1/js/anychart-base.min.js"></script>
<script src="anychart_8.13.1/js/anychart-ui.min.js"></script>
<script src="anychart_8.13.1/js/anychart-exports.min.js"></script>
<script src="anychart_8.13.1/js/anychart-gantt.min.js"></script>
<script src="anychart_8.13.1/js/anychart-data-adapter.min.js"></script>
<link href="anychart_8.13.1/css/anychart-ui.min.css" type="text/css" rel="stylesheet">
<link href="https://cdn.anychart.com/releases/8.13.1/fonts/css/anychart-font.min.css" type="text/css" rel="stylesheet">        
EOT;
if ($_SESSION['language'] == "Spanish") {
$language = 'es-es';
$dateFormat='dd/MM/yyy';
$textName = 'Nombre';
$textProgress = 'Progreso';
$html .= <<<EOT
<script src="anychart_8.13.1/locales/es-es.js"></script>
EOT;
} else {
$language='en-us';
$dateFormat='MM/dd/yyy';
$textName = 'Name';
$textProgress = 'Progress';
$html .= <<<EOT
<script src="anychart_8.13.1/locales/en-us.js"></script>
EOT;
}

$records =[];

// Read Project the Project
$sql="SELECT (999000000+p.project_id) task_id,
    concat(p.short_name,' - ',p.name) name,
    DATE_FORMAT(p.`start_date`,'%Y-%m-%d') `start_date`,
    DATE_FORMAT(ifnull(p.`actual_end_date`,p.`end_date`),'%Y-%m-%d') `end_date`,
    0  `dynamic`,
    '' `related_url`,
    0  `milestone`,
    p.`percent_complete`,
    0   parent,
    0   dependent,
    p .`description`,
    ifnull(u.login,'') creator
FROM projects p
  inner join users u on (p.owner=u.user_id)
WHERE p.project_parent = $keyMaster";
$resql = DB::Query($sql);

 while ($row = $resql->fetchAssoc()) {
            $item = [
                "id" =>  $row['task_id'],
                "name" => addslashes($row['name']),
                "actualStart" => strtotime($row['start_date']) * 1000,
                "actualEnd" => strtotime($row['end_date']) * 1000,
                "progressValue"=> $row['percent_complete'] / 100,
                "collapsed" => true,
                "task_link" => $row['related_url'],
                "task_owner" => $row['creator'],
                "task_description" => $row['description'],
                "task_note" => addslashes(str_replace(hex2bin('0a'),'',str_replace(hex2bin('0d'),'',str_replace(PHP_EOL,'<br />', $task_description)))),
                "actual"=> [
                    "fill"=> "#4CAF50",     // color de la barra de fondo
                    "stroke"=> "#2E7D32"    // borde opcional
                    ],
                "progress"=> [
                    "fill"=> "#319435"      // color del % ejecutado
                ],
            ];
            // Añadir al array principal
            $records[] = $item;       
    }
    
// Read Task the Project
$sql="SELECT
        t.`task_id`,
        t.`name`,
        DATE_FORMAT(t.`start_date`,'%Y-%m-%d') `start_date`,
        DATE_FORMAT(t.`end_date`,'%Y-%m-%d') `end_date`,
        t.`dynamic`,
        t.`related_url`,
        t.`milestone`,
        t.`percent_complete`,
        ifnull(t.`parent`,0) parent,
        ifnull(t.`dependent`,0) dependent,
        t.`description`,
        ifnull(u.login,'') creator
FROM tasks t
        inner join users u on (t.creator=u.user_id)
WHERE t.`projects_project_id` = $keyMaster
ORDER BY t.`order`";
$resql = DB::Query($sql);

 while ($row = $resql->fetchAssoc()) {
            $item = [
                "id" =>  $row['task_id'],
                "name" => addslashes($row['name']),
                "actualStart" => strtotime($row['start_date']) * 1000,
                "actualEnd" => strtotime($row['end_date']) * 1000,
                "progressValue"=> $row['percent_complete'] / 100,
                "collapsed" => true,
                "task_link" => $row['related_url'],
                "task_owner" => $row['creator'],
                "task_description" => $row['description'],
                "task_note" => addslashes(str_replace(hex2bin('0a'),'',str_replace(hex2bin('0d'),'',str_replace(PHP_EOL,'<br />', $task_description)))),              
            ];
            // Si tiene parent
            if (!empty($row['parent'])) {
                $item["parent"] = $row['parent'];
            }
            // Si tiene conexión
            if (!empty($row['dependent'])) {
                $item["connectTo"] = $row['dependent'];
            }
            // Si es Milestone
            if (!empty($row['milestone'])) {
                $item["actualEnd"] = strtotime($row['start_date']) * 1000;
            }
            
            // Añadir al array principal
            $records[] = $item;           
    }

 $data = json_encode($records, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK);

$html .= <<<EOT
<style>
    #container1 {
        margin: 0;
        padding: 0;
        width: 100%;
        height: 45vh;
        display: flex;
        flex-direction: column;
    }

</style>

<div id="container1"></div>

<script>
    let chart;

    const data = $data; 

    anychart.onDocumentReady(function () {
        
        // Activar locale español (funciona con CDN)
        anychart.format.inputLocale('$language');
        anychart.format.inputDateTimeFormat('$dateFormat'); //Like '12/03/2012'
        anychart.format.outputLocale('$language');

        var treeData = anychart.data.tree(data, "as-table");
        // console.log("treeData: ", treeData);

        chart = anychart.ganttProject();

        // Licencia (si la tienes)
        window.anychart.licenseKey('XXXXXXXXX-XXXXXXXXX-XXXXXXXXX');
        var credits = chart.credits();
        credits.enabled(false);
   
        // Activa el panel de controles (export, print, fullscreen)
        var exports = chart.exports();
        // console.log("exports: ", exports);
        
        // Set client side export settings.
        exports.clientside({
        'path': 'https://cdn.anychart.com/3rd/', // path to dependencies
        'enabled': true, // clientside export enabled
        'fallback': false // fallback to server side exporting
        });

        chart.data(treeData);

        // set chart row's height
        chart.defaultRowHeight(30);

        // set start splitter position settings
        chart.splitterPosition(324);

        // get chart data grid
        var dataGrid = chart.dataGrid();

        // set data grid's first column settings
        dataGrid.column(0).width(30).labels({
        hAlign: 'center'
        });

        // set data grid's seconds column settings
        dataGrid
        .column(1)
        .collapseExpandButtons(false) // disable collapse/expand buttons
        .width(180)
        .title('$textName');

        // create data grid's third column and set it's settings
        dataGrid.column(2).width(80).title('$textProgress');

        // create column with collapse/expand buttons
        dataGrid
        .column(3)
        .title(null)
        .width(30)
        .collapseExpandButtons(true);

        // set column labels overrider
        dataGrid.column(0).labelsOverrider(labelsTextSettings);
        dataGrid.column(1).labelsOverrider(labelsTextSettings);
        dataGrid.column(2).labelsOverrider(labelsTextSettings);

        // set labels formatter for the third column
        dataGrid
        .column(2)
        .labels()
        .format(function () {
            if (this.item.numChildren()) {
            // formatter for groupping tasks data grid labels
            if (this.progress !== 0) {
                return this.progress < 1 ? 'In progress' : 'Done';
            }
            }
            if (this.item.get('actualEnd')) {
            // format labels in group
            return Math.round(this.progress * 100) + '%';
            }
            if (this.item.getParent()) {
            // formatter for milestones
            return parseInt(
                this.item.getParent().get('progressValue')
            ) === 100
                ? 'Ok'
                : '';
            }
        });

        // get data grid buttons
        var button = dataGrid.buttons();

        // set buttons width and height
        button.width(20).height(15);

        // disable button's background for every state
        button.background(false);
        button.hovered().background(false);
        button.selected().background(false);

        // set data grid button's content using function
        var buttonContent = function (path) {
        // get center of button's path
        var centerX = this.width / 2;
        var centerY = this.height / 2;

        // draw arrow in path
        if (this.state === 'selected') {
            path
            .clear()
            .moveTo(3, 4)
            .lineTo(centerX, this.height - 2)
            .lineTo(this.width - 3, 4)
            .stroke('#00a8e0', 3, 'solid', 'miter', 'round');
        } else {
            path
            .clear()
            .moveTo(6, 2)
            .lineTo(this.width - 4, centerY + 1)
            .lineTo(6, this.height)
            .stroke('#748a8d', 3, 'solid', 'miter', 'round');
        }
        };

        // set function as content of button
        button.content(buttonContent);
        button.selected().content(buttonContent);

        // get timeline
        var timeline = chart.getTimeline();
        // get timeline's scale
        var scale = timeline.scale();
        
        // configure the scale
        // scale.zoomLevels([["week", "month", "quarter"]]);
        scale.zoomLevels([["week",  "quarter"]]);
            
        // Set scale maximum and minimum.
        scale.minimumGap(10,true);
        scale.maximumGap(15,true);

        // zoom chart all dates range
        // chart.fitAll();
        // zoom the timeline to the given units
        chart.zoomTo("week", 25, "first-date");  // Ponemos 25 semanas de visualización
  
        // Crear un marcador vertical
        const todayMarker = timeline.lineMarker();
        // Fecha fija: 2010-02-15
        const fechaFija = new Date().getTime();
        // const fechaFija = new Date("2010-05-02").getTime();

        // Configurar el marcador
        todayMarker
            .value(fechaFija)          // fecha fija en milisegundos
            .stroke('#ff0000', 2)      // color y grosor
            .scale(timeline.scale());  // importante: usar la escala del timeline

        // hide groupping tasks progress bars
        timeline.groupingTasks().progress().fill(null).stroke(null);

        timeline
        .groupingTasks()
        .progress()
        .selected()
        .fill(null)
        .stroke(null);

        // set shapes for groupping tasks rendering
        timeline
        .groupingTasks()
        .rendering()
        .shapes([
            {
            name: 'task',
            shapeType: 'path',
            disablePointerEvents: false
            }
        ]);

        // set custom drawer for groupping tasks
        timeline.groupingTasks().rendering().drawer(groupingTaskDrawer);

        // set groupping tasks bar's position
        timeline.groupingTasks().anchor('center').position('center');

        // set groupping tasks labels settings
        timeline
        .groupingTasks()
        .labels()
        .format(function () {
            return this.name;
        })
        .fontWeight('bold')
        .fontColor('#fff')
        .anchor('center')
        .position('center');

        // set timeline tasks fill and stroke settings
        timeline.tasks().selected().fill('#1876d1');
        timeline.tasks().selected().stroke('#1876d1');

        // set timeline tasks progress fill and stroke settings
        timeline.tasks().progress().selected().fill('#203951');
        timeline.tasks().progress().selected().stroke('#203951');

        chart.container("container1");
        chart.draw();

        // set "collapsed" data value after row collapse/expand
        chart.listen('rowCollapseExpand', function (e) {
            e.item.set('collapsed', e.collapsed);
        });  
        
        // configure tooltips of the timeline
        chart.getTimeline().tooltip().useHtml(true);    
        chart.dataGrid().tooltip().useHtml(true); 
        chart.getTimeline().tooltip().format(
          "<span style='font-weight:600;font-size:12pt'>" +
          "{%actualStart}{dateTimeFormat:dd MMM} - " +
          "{%actualEnd}{dateTimeFormat:dd MMM}</span><br><br>" +
          "Progress: {%progress}<br>" +
          "Manager: {%task_owner}"
        );
                
        // listen for task edit event
        chart.listen("rowDblClick", function (e) {
            const item = e.item; // tarea seleccionada
            const id = item.get("id");
            const name = item.get("name");
            const start = item.get("actualStart");
            const end = item.get("actualEnd");
            const progress = item.get("progressValue");

            console.log("Tarea seleccionada:", { id, name, start, end, progress });

            // Aquí llamas a tu modal o lógica de edición
            editarTarea({ id, name, start, end, progress });
        });

        // Captura de Tarea desde TimeLine
        /*
        chart.getTimeline().listen("pointClick", function (e) {
            const item = e.item;
            console.log("Click en barra:", item.get("id"));
            editarTarea({
                id: item.get("id"),
                name: item.get("name"),
                start: item.get("actualStart"),
                end: item.get("actualEnd"),
                progress: item.get("progressValue")
            });
        });
        */
        
        // set tasks labels settings
        timeline.tasks().labels().useHtml(true);
        timeline.tasks().labels()
            .format("- <span style='color:#ffffff'>{%progress}</span>")
            .anchor('center')
            .position('center');



    });

    // custom drawer function for the groupping tasks
    function groupingTaskDrawer() {
        var path;
        var itemBounds;

        // get path from shapes
        path = this.shapes.task;

        // get recommended bounds for drawing
        var bounds = this.predictedBounds;

        // get shift value
        var shift = halfShift(path.strokeThickness());

        // set bar's bounds
        itemBounds = anychart.math.rect(
            Math.round(bounds.left) - shift,
            Math.round(bounds.top) - 5 - shift,
            Math.round(bounds.width) - shift,
            21 - shift
        );

        // get progress value from data
        var progress = parseInt(this.item.get('progressValue')) / 100;

        // set path's fill and stroke settings
        path.fill(function () {
            if (progress !== 0) {
            return progress < 1 ? '#f4a700' : '#27a858';
            }
            return '#e51a23';
        });
        path.stroke(null);

        // draw rounded rectangle on the path
        anychart.graphics.vector.primitives.roundedRect(
            path,
            itemBounds,
            0
        );
    }

    function halfShift(strokeThickness) {
        return strokeThickness % 2 ? 0.5 : 0;
    }

    // labels overrider function
    function labelsTextSettings(label, item) {
        //  filter data items with "collapsed" = true
        var collapsedItems = chart.data().filter(function (item) {
            return item.get('collapsed');
        });
        if (item.numChildren()) {
            // override only groupping tasks labels
            // set label's weight
            label.fontWeight('bold');

            // set label's color
            label.fontColor(
            collapsedItems.indexOf(item) > -1 ? '#748a8d' : '#00a8e0'
            );
        }
    }

    // Actualizar tarea después de editarla
    function actualizarTarea(id, cambios) {
        const dataItem = chart.data().search("id", id);

        if (dataItem) {
            Object.entries(cambios).forEach(([key, value]) => {
                dataItem.set(key, value);
            });

            chart.draw(); // refresca el gráfico
        }
    }
    /* ejemplo de actualización de tarea:
    actualizarTarea("step1", {
        actualStart: new Date("2024-05-01").getTime(),
        actualEnd: new Date("2024-05-10").getTime(),
        progressValue: "80%"
    });
    */

    // Función para abrir modal de visualización y edición 
    function editarTarea(item ) {
        console.log("Editar tarea:", item);
        const isFullScreen = !!document.fullscreenElement;  // Controla si está en pantalla completa
        if (!isFullScreen && item.id < 10000000 ) {  //sólo cuando no está pantalla comleta y no en proyecto dependiente,  se "levanta" ventana popup
            var Ev_id = item.id;
            var title = item.name;
            var url = "tasks_view.php?page=view_gantt&editid1="+Ev_id;
            var header = '<h2 data-itemtype="view_header" data-itemid="view_header" data-pageid="10">'+'Tarea: '+ title+'</h2>' ;
            window.popup = Runner.displayPopup({
                url: url,
                width: 1000,
                height: 600,
                header: header
               });
        }
    };
  
</script>
EOT;
echo $html;
?>

Para los que no dispongan de PHPRunner, debéis tener en cuenta que el KIt que os dejo requiere:

  • Web Server Apache o similar, con PHP 8.2 o PHP 8.3
  • MySQL o Maria DB.

Las instrucciones de instalación son simples:

  • Descargar el KIT de instalación sin PHPRunner.
  • Desplegar el zip de la aplicación en el directorio del «document Root» de tu web Server, por ejemplo, directorio «project».
  • Crear una base de datos, por ejemplo «project» y crear todos los objetos con el Backup del modelo de datos.
  • Configurar el fichero «config.php», que si has instalado en el directorio «project» sería el path «project/config.php» y ahí escribir los datos de la conexión a la base de datos.
  • Con eso, ya tienes el producto instalado y el ususario por defecto es «admin»/»admin». Con este usuario, ya puedes cambiar el resto de configuración, así como los usuarios existentes o nuevos.

Como he tenido que recordar cómo estaba hecho el producto, en estos momentos puedo resolver cualquier error que detectéis o ampliar la solución a cualquier otro requerimiento que el producto no tenga.
Si necesitáis algo, escribirme.

Para cualquier duda o lo que necesitéis, contactar conmigo a través del email [email protected]

Os facilito los fuentes, de las 3 últimas versiones,  para que lo podáis instalar y modificar en vuestros Windows.

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner