Gestor de Proyectos (Actualizado 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 al email [email protected]

Para acceder a toda la información del artículo, haz clic en este enlace.

Gestor de Cita Previa (2)

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.

Esta versión que vengo a explicar y a definir unos componentes para su desarrollo, es también de cita previa, pero desde que se facilita la cita, se establece el tiempo de actividad y por lo tanto no es fijo, si no variable. Puede ser ejemplo de este tipo de actividad un despacho de Odontólogos/Dentistas, un salón de peluquería, masajes o manicura, etc., en aquellas actividades en donde se establece tiempos distintos dependiendo de la actividad que requiera el cliente.

Objetivo

Definir una propuesta técnica/desarrollo de una gestión de cita previa  para actividades que se ejecutan con tiempos distintos y que conllevan la asignación de un profesional para la realización de la actividad.

DEMO: https://fhumanes.com/citas/

Funcionamiento:

  1. – Buscar y seleccionar al Cliente. Si es nuevo, se daría de alta.
  2. – Pulsando en el calendario rojo se va a la pantalla que muestra los datos del usuario y un calendario con los horarios ocupados y asignados a los profesionales.
  3. – Por defecto saldrá el calendario del último profesional que le atendió, pero se puede cambiar en cualquier momento, si al Cliente le interesase o porque el trabajo solicitado lo ejecutase otro profesional.
  4. – Haciendo clic en el día y horario solicitado, se abre la ventana de la cita en la que ya está por defecto el nombre del Cliente y si en la selección del profesional, sólo se fijó uno, también, sale seleccionado ese profesional, Se indica la actividad y con ello, viene el tiempo que se estima para hacer dicha actividad y se actualiza la nueva cita, quedando actualizado calendario y los datos de última cita del Cliente.
  5. – Seleccionando cualquier cita existente del calendario, se podría consultar un modificar, para ajustarla a las nuevas necesidades.

Si estáis interesados en este contenido seguir leyendo el artículo.

S-019 – Añadir Tailwind y Daisy UI, a componentes de SVAR

Este ejercicio era algo que quería emprender hace mucho tiempo. Los componentes de SVAR, me gustan, me parecen bastantes completos, creo que pueden ser muy eficientes para escribir código rápidamente y que también pueden ser muy útiles para aquellos que empiezan en el desarrollo, porque hay muchas que hacen por tí y que suelen estar muy bien hechas.

Ahora bien, para mí, tiene una definición de temas que es bastante «oscuro», ya que no disponen de una documentación sencilla y completa, para que podamos ajustar estos temas a nuestra necesidades o gustos.

Al haber terminado el ejemplo de Tailwind CSS y Daisy UI, ver la facilidad que ofrecen a los desarrolladores para los ajustes de UI de las aplicaciones, pensé que una solución de ese tipo requería los componentes de SVAR.

Objetivo

Integrar Tailwind CSS + Daisy UI + SVAR, de cara a mejorar la funcionalidad de personalización del UI de los desarrollos hecho con los componentes de SVAR.

DEMO: https://fhumanes.com/movie-svar/

Si estas interesado en este tema, sigue leyendo el artículo en este enlace.

S-018 – Integración Tailwind CSS + Daisy UI + Felte + Zod en Svelte

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.

De ahí surgió la utilización de estos productos:

  • Para interface de aplicación, UI.- He seleccionado Tailwind CSS, que prácticamente es un estándar y que es utilizado masivamente por todos (mucho más que Bootstrap) y se complemente con Daisy UI que facilita el uso de TailWind CSS y nos dota de Temas, componentes para la iteración con el usuario, etc. Muy, muy importante y excelente solución, que nos soluciona la escritura de los componentes de iteración con el usuario.
  • Para la lógica y validación de los formularios.- Los productos seleccionados no tienen tanta difusión, pero son un buen ejemplo del ecosistema de soluciones que hay en el entorno de Svelte.
    Felte, nos soluciona la arquitectura de los formularios y Zod, nos facilta la definición de esquemas de validación de los datos de entrada de los formularios.
Objetivo

Repetir el proyecto de Bootstrap Svelte, con este kit de productos más propio del ecosistema de Svelte y verificar las bondades de utilización de las soluciones más habituales de los componentes de Svelte.

Se mantiene la aplicación de Back-End desarrollada en PHP SLIM4.

DEMO: https://fhumanes.com/felte-svelte/

Si estás interesado en este tema, sigue leyendo el artículo en este enlace.

S-017 – Integración de PivotGrid de DevExtreme en Svelte

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:

  • Recupera los datos de la base de datos de forma inmediata y su visualización es muy potente.
  • Desde la propia visualización se puede realizar actualizaciones en los registros, de tal forma que la gestión es mucho más rápida y dinámica.

Como en otras librerías, se ha hecho 2 integraciones:

  • CDN – Utilizando la versión de librería de JavaScript.
  • NPM – Utilizando la versión de módulo de JavaScript.

Objetivo

Disponer de una solución potente de Pivot Grid para el entorno de Svelte y aprovechando el conocimiento previo, verificar que la solución de DevExpress, aunque en la web no lo indiquen, se puede utilizar perfectamente con Svelte 5.

DEMO

Si te interesa este tema, sigue leyendo el artículo en este enlace.

 

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