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:
<?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 al email [email protected]
Para acceder a toda la información del artículo, haz clic en este enlace.
La versión anterior de Cita Previa, trabaja bajo el concepto de cita para un evento/actividad que tiene una duración fija (sistema médico de salud, gestión administración pública, etc.), donde se estima que es un tiempo promedio fijo para la actividad con el Ciudadano/Cliente.

Este ejercicio era algo que quería emprender hace mucho tiempo. Los componentes de 
Después de múltiples ejemplos, integrar las mismas soluciones que integré en PHPRunner (aplicaciones Web), me he cuestionado si estoy utilizando Svelte adecuadamente y sobre todo, con los productos más habituales que los programadores «nativos» de Svelte, utilizan.
Este componente lo he utilizado con PHPRunner (aplicación Web) y para las aplicaciones de Gestión en una oficina es una solución muy potente, pues se asemeja a una tabla Pivot de Excel, pero con muchas ventajas: