Custom component Gantt Chart

Hello,

I have created a retool based Gantt using the excellent DHTMLx library.

It reads an SQL database that based on the SuiteCRM tasks table (for anyone creating a custom CRM I would recommend looking at SuiteCRM SQL for a structure). The SQL data is passed through a transformer to format it to DHTMLx format. There is some jiggerypokery for the gantt to accept SuiteCRM UUIDs.

It has some limited resourcing, assigning tasks etc (no notifications yet) and can deal with sub-tasks. DHTMLx has further paid for services to access a really powerful resourcing scheduler.

Its currently setup for RIBA workstages and changes colour based on the stage or certain phrases. We are using to plan work from a fee proposal (multiple fee proposals / budgets per project).

The code is based on lots of the DHTMLx tutorials.

<!DOCTYPE html>
<html>
<head> 
<style>
    /* RIBA Stages Colour */
    .gantt_task_line{
        border-color: rgba(0, 0, 0, 0.25);
        border:1px solid #787878;
    }
    .gantt_task_line .gantt_task_progress {
        background-color: rgba(0, 0, 0, 0.25);
    }
    .gantt_task_line.stage0 {
        background-color: #F89B34;
        border:1px solid #787878;
    }
    .gantt_task_line.stage0 .gantt_task_content {
        color: #fff;
    }
    .gantt_task_line.stage1 {
        background-color: #EEA2C7;
      border:1px solid #787878;
    }
    .gantt_task_line.stage1 .gantt_task_content {
        color: #fff;
    }
    .gantt_task_line.stage2 {
        background-color: #66B7BD;
      border:1px solid #787878;
    }
    .gantt_task_line.stage2 .gantt_task_content {
        color: #fff;
    }
  .gantt_task_line.stage3 {
        background-color: #FFD420;
    border:1px solid #787878;
    }
    .gantt_task_line.stage3 .gantt_task_content {
        color: #fff;
    }
  .gantt_task_line.stage4 {
        background-color: #85BE9B;
    border:1px solid #787878;
    
    }
    .gantt_task_line.stage4 .gantt_task_content {
        color: #fff;
    }
  .gantt_task_line.stage5 {
        background-color: #A4A8D5;
    border:1px solid #787878;
    }
    .gantt_task_line.stage5 .gantt_task_content {
        color: #EFD399;
    }
  .gantt_task_line.stage6 {
        background-color: #EFD399;
    border:1px solid #787878;
    }
    .gantt_task_line.stage6 .gantt_task_content {
        color: #fff;
    }
  .gantt_task_line.stage7 {
        background-color: #5DAADA;
    border:1px solid #787878;
    }
    .gantt_task_line.stage7 .gantt_task_content {
        color: #fff;
    }
  .gantt_task_line.other {
        background-color: #787878;
    border:1px solid #787878;
    }
    .gantt_task_line.other .gantt_task_content {
        color: #fff;
    }

</style>
   <script src="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.js"></script>
   <link href="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.css" rel="stylesheet">
  <script
    src="https://code.jquery.com/jquery-3.3.1.min.js?v=5.2.4"
    integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
    crossorigin="anonymous"></script>
 
<script src="https://cdnjs.cloudflare.com/ajax/libs/chosen/1.8.7/chosen.jquery.js?v=5.2.4"></script>
<link rel="stylesheet" type="text/css" 
    href="https://cdnjs.cloudflare.com/ajax/libs/chosen/1.8.7/chosen.css?v=5.2.4">
</head>
<input type=button style="background-color:#3C92DC; color: #fff; border: 0; border-color:#266EBD;
    border-radius:4px;" value="Zoom +" onclick="gantt.ext.zoom.zoomIn();">
<input type=button style="background-color:#3C92DC; color: #fff; border: 0; border-color:#266EBD;
    border-radius:4px;"value="Zoom -" onclick="gantt.ext.zoom.zoomOut();">
  <input type=button style="background-color:#3C92DC; color: #fff; border: 0; border-color:#266EBD;
    border-radius:4px;"value="Save" onclick="taskModelUpdate();">
<body>
    <div id="gantt_here" style='width:100%; height:400px;'></div>
    <script type="text/javascript">
 function taskModelUpdate(id){
  try{
    let dataOut = gantt.serialize();
    window.Retool.modelUpdate({taskDataUpdate: dataOut});
    window.Retool.triggerQuery("ganttDataDelete");
  }
  catch(e){
    window.Retool.modelUpdate({taskDataUpdate: e});
    
  }
 };


window.Retool.subscribe(function(model) {
              if (!model) {
                return;
              }
  
  gantt.form_blocks["multiselect"] = {
 render: function (sns) {
  var height = (sns.height || "23") + "px";
  var html = "<div class='gantt_cal_ltext gantt_cal_chosen gantt_cal_multiselect'"+
     "style='height:"+ height + ";'><select data-placeholder='...'"+
        "class='chosen-select' multiple>";
  if (sns.options) {
   for (var i = 0; i < sns.options.length; i++) {
    if(sns.unassigned_value !== undefined && sns.options[i].key==sns.unassigned_value){
        continue;
    }
    html+="<option value='" +sns.options[i].key+ "'>"+sns.options[i].label+"</option>";
  }
}
  html += "</select></div>";
  return html;
},
 
set_value: function (node, value, ev, sns) {
    node.style.overflow = "visible";
    node.parentNode.style.overflow = "visible";
    node.style.display = "inline-block";
    var select = $(node.firstChild);
 
    if (value) {
        value = (value + "").split(",");
        select.val(value);
    }
    else {
        select.val([]);
    }
 
    select.chosen();
    if(sns.onchange){
        select.change(function(){
            sns.onchange.call(this);
        })
    }
    select.trigger('chosen:updated');
    select.trigger("change");
},
 
get_value: function (node, ev) {
    var value = $(node.firstChild).val();
    //value = value ? value.join(",") : null
    return value;
},
 
focus: function (node) {
    $(node.firstChild).focus();
 }
};

gantt.templates.parse_date = function(date) { 
    return new Date(date);
};
gantt.templates.format_date = function(date) { 
    return date.toISOString();
};
  
var hourToStr = gantt.date.date_to_str("%H:%i");
var hourRangeFormat = function(step){
    return function(date){
        var intervalEnd = new Date(gantt.date.add(date, step, "hour") - 1)
        return hourToStr(date) + " - " + hourToStr(intervalEnd);
    };
};
var zoomConfig = {
    levels: [
      {
        name:"day",
        scale_height: 27,
        min_column_width:80,
        scales:[
            {unit: "day", step: 1, format: "%d %M"}
        ]
      },
      {
         name:"week",
         scale_height: 50,
         min_column_width:50,
         scales:[
          {unit: "week", step: 1, format: function (date) {
           var dateToStr = gantt.date.date_to_str("%d %M");
           var endDate = gantt.date.add(date, 6, "day");
           var weekNum = gantt.date.date_to_str("%W")(date);
           return "#" + weekNum + ", " + dateToStr(date) + " - " + dateToStr(endDate);
           }},
           {unit: "day", step: 1, format: "%j %D"}
         ]
       },
       {
         name:"month",
         scale_height: 50,
         min_column_width:120,
         scales:[
            {unit: "month", format: "%F, %Y"},
            {unit: "week", format: "Week #%W"}
         ]
        },
        {
         name:"quarter",
         height: 50,
         min_column_width:90,
         scales:[
          {unit: "month", step: 1, format: "%M"},
          {
           unit: "quarter", step: 1, format: function (date) {
            var dateToStr = gantt.date.date_to_str("%M");
            var endDate = gantt.date.add(gantt.date.add(date, 3, "month"), -1, "day");
            return dateToStr(date) + " - " + dateToStr(endDate);
           }
         }
        ]},
        {
          name:"year",
          scale_height: 50,
          min_column_width: 30,
          scales:[
            {unit: "year", step: 1, format: "%Y"}
        ]}
    ]
};
gantt.config.open_tree_initially = true;
gantt.ext.zoom.init(zoomConfig);
gantt.ext.zoom.setLevel("week");
var userData = model.userData;
var ganttData = model.tasks;
gantt.serverList("users", userData);
gantt.plugins({
    tooltip: true
});

gantt.templates.tooltip_text = function(start,end,task){
  const tipList = [ task.text];
  if (task.duration!=null){
    tipList.push( "<br/><b>Hours Target:</b>"+task.duration)
  }
  if (task.hours_actual!=null){
    tipList.push("<br/><b>Hours Actual:</b> "+task.hours_actual);
  }
    return tipList.join("");
};
gantt.templates.task_text = function(start, end, task){  
  if (task.owner==null){
    return task.text;    }
  else{
   let taskTxt = task.text+" - "+task.owner.map(a => {
     return userData.map(l => l.label.split(" ").map(n => {
       return n[0]}).join(""))[userData.map(u => u.key).indexOf(a)]}).join(", ");     
  return taskTxt;
  };
};


gantt.config.lightbox.sections = [
  {name: "description", height: 22, map_to: "text", type: "textarea", focus: true},
  {name: "tasksummary", height: 45, map_to:"task_summary",type:"textarea"},
  {name: "hoursactual", height: 22, width:45, map_to:"hours_actual",type:"textarea"},
  {name: "owner", height: 22, map_to: "owner", type: "multiselect", options: gantt.serverList("users")},
  {name: "time", type: "duration", map_to: "auto"}//,format:formatter}
];
  
gantt.config.columns = [
  {name:"text", label:"Task name", tree:true, width:170 },
  {name:"owner", label:"Team",align: "center", width:100, template: function (item) {
        if (!item.owner) return "...";
        return item.owner.map(a => {
          return userData.map(l => l.label.split(" ").map(n => {return n[0]}).join(""))[userData.map(u => u.key).indexOf(a)]}).join(", ");
      }},
  {name:"start_date", align: "center", width: 90},
  {name: "hours_target", label:"Hours Target", resize: true, align: "center", 
        template: function(task) {
          if (!task.text.includes('FP') && !task.text.includes("Stage")){
            return task.duration};
            //return formatter.format(task.duration)};
          return "";
        }, width: 80},
  {name:"hours_actual",label:"Hours Actual",height:22,width:80, align:"center"},
  {name:"add", width:40}
];
gantt.locale.labels.section_owner = "Team";
gantt.locale.labels.section_description = "Task";
gantt.locale.labels.section_tasksummary = "Task Summary";
gantt.locale.labels.section_hoursactual = "Actual Hours taken";
gantt.config.time_step = 60;
gantt.config.round_dnd_dates = false;

var formatter = gantt.ext.formatters.durationFormatter({
    enter: "hour", 
    store: "minute", // duration_unit
    format: "hour",
    hoursPerDay: 8,
    hoursPerWeek: 40,
    daysPerMonth: 30
});
gantt.setWorkTime({ hours:["9:00-18:00"] });
gantt.setWorkTime({ day:6, hours:false });
gantt.setWorkTime({ day:7, hours:false });
gantt.config.work_time = true;
//gantt.config.time_step = 60;
gantt.config.round_dnd_dates = false;
//gantt.config.duration_step = 1;
gantt.config.duration_unit = "hour";
gantt.init("gantt_here");
//window.Retool.triggerQuery("ganttDataFromCRM");
if (!model.taskDataUpdate.data){
  gantt.parse({data: ganttData});

}else{
  gantt.parse({data: model.taskDataUpdate.data});
}
//gantt.attachEvent("onAfterTaskUpdate",taskModelUpdate(id,item));
gantt.templates.task_class = function(start,end,task){
  const ribaStageInt = parseInt(task.text.match(/\d+/)[0]);
    if(task.text.includes("FP")){
        return "other";
    }else{
      try{
        return ["stage",ribaStageInt].join("");
      }
      catch{
        return "other";
      }
        return "other";
    }
};


});

    </script>
</body>
</html>

2 Likes

@ivthecat, this is awesome, thank you for sharing :raised_hands:

This looks excellent for my use case! I am just struggling to get it to work in my app as I am unfamiliar with html

You might want to check out our new Timeline component for this use case as well: Introducing the new Timeline component

Your setup with DHTMLx for a Gantt chart sounds really impressive and practical, especially integrating with SuiteCRM tasks. I've tinkered with similar setups myself for project planning, and it's amazing how these tools can streamline workflows. For me, having a Gantt chart has been a game-changer in visualizing project stages and managing tasks effectively. It's like having a roadmap that adapts to changes seamlessly. Keep refining it—I'm sure integrating notifications will add another layer of efficiency!