Files
crmPRO/libraries/JS-Gantt-Chart-master/gantt.js
Jacek Pyziak 1f38807599 feat: Integrate JS Gantt Chart library and update styles
- Added JS Gantt Chart library files including CSS and JS.
- Updated existing Gantt chart styles for better visual representation.
- Translated month and day names to Polish in the Gantt chart settings.
- Implemented Gantt chart initialization in the main view with sample data.
- Added a toggle switch for displaying tasks in the calendar.
- Enhanced task edit form with a new checkbox for calendar visibility.
- Improved the layout of the logged-in user template to include Gantt chart styles and scripts.
2025-04-11 00:26:54 +02:00

399 lines
16 KiB
JavaScript

class Gantt {
constructor(staticID, params) {
this.staticID = staticID;
this.sidebarHeader = params.sidebarHeader || 'Unused parameter right now';
this.noDataFoundMessage = params.noDataFoundMessage || 'No data found.';
this.startTimeAlias = params.startTimeAlias || 'startTime';
this.endTimeAlias = params.endTimeAlias || 'endTime';
this.idAlias = params.idAlias || 'id';
this.rowAlias = params.rowAlias || 'rowTitle';
this.linkAlias = params.linkAlias;
this.tooltipAlias = params.tooltipAlias || 'tooltip';
this.groupBy = params.groupBy ? params.groupBy.split(',').map(group => group.trim()) : [];
this.groupByAlias = this.groupBy ? (params.groupByAlias ? params.groupByAlias.split(',').map(group => group.trim()) : this.groupBy) : [];
this.data = {};
this.rawData = [];
this.divisionCount = 0;
this.maxTime;
this.minTime;
this.refreshFunction = params.refreshFunction;
this.wrapper = document.createElement('div');
this.wrapper.classList.add('gfb-gantt-wrapper');
document.getElementById(this.staticID).appendChild(this.wrapper);
this.refreshData();
}
refreshData() {
this.rawData = this.refreshFunction();
this.empty();
if(this.rawData.length < 1){
this.noDataFound();
return;
}
this.processData();
this.render();
}
processData() {
//Have to reset the divisionCount here. Otherwise it keeps incrementing on the divisionCount of the previous data set.
this.divisionCount = 0;
//Sorts the data by the start time ascending before anything else is done
//This ensures that the data is rendered in the proper location when we generate the chart.
let sortedData = this.rawData.sort((a, b) => new Date(a[this.startTimeAlias]) - new Date(b[this.startTimeAlias])),
groupedData = {};
for (let dataRow of sortedData) {
this.groupArray(groupedData, dataRow);
//console.log(groupedData);
}
this.data = groupedData;
//Get the max and min datetimes in the dataset
//Used to determine the first and last times that are shown in the chart
let maxTime = this.rawData.reduce((max, curr) => !!curr[this.endTimeAlias] ? max > new Date(curr[this.endTimeAlias]) ? max : new Date(curr[this.endTimeAlias]) : max, new Date(0));
let minTime = this.rawData.reduce((min, curr) => !!curr[this.startTimeAlias] ? min > new Date(curr[this.startTimeAlias]) ? new Date(curr[this.startTimeAlias]) : min : min, new Date(maxTime.getTime()));
this.maxTime = roundHourUp(maxTime);
this.minTime = roundHourDown(minTime);
if(this.minTime === this.maxTime)
return;
//Determines the number of columns(times) that will be shown in the chart.
//Mostly used for iteration later in the render process
for (let i = new Date(this.minTime.getTime()); i <= this.maxTime; i.setTime(i.getTime() + (60 * 60 * 1000))) {
this.divisionCount++;
}
}
//Takes advantage of the fact that javascript is kind of pass-by-reference for object data types
//So every change to result here is actually a change to our groupedData variable
groupArray(result, entry, iter = 0) {
let nextResult;
if (iter === this.groupBy.length + 1) {
result.push(entry);
return;
}
let groupingProperty = result[entry[this.groupBy[iter]]];
let chartEntry = result[entry[this.idAlias]];
//If we can't find a property within result for the groupBy parameter at this level we create a new one.
if (!result[entry[this.groupBy[iter]]] && iter < this.groupBy.length) {
result[entry[this.groupBy[iter]]] = {groupName: entry[this.groupByAlias[iter]]};
nextResult = result[entry[this.groupBy[iter]]];
}
else if (!result[entry[this.idAlias]] && iter === this.groupBy.length) {
result[entry[this.idAlias]] = [];
nextResult = result[entry[this.idAlias]];
}
else if (result[entry[this.groupBy[iter]]]) {
nextResult = result[entry[this.groupBy[iter]]];
}
else if (result[entry[this.idAlias]]) {
nextResult = result[entry[this.idAlias]];
}
iter++;
this.groupArray(nextResult, entry, iter);
}
//Creates the header for the Gantt chart
buildHeader() {
let headerDivs = `<div class="gfb-gantt-header-spacer"></div>`;
if (this.divisionCount > 1) {
for (let i = 0; i < this.divisionCount; i++) {
let date = new Date(this.minTime.getTime() + ((60 * 60 * 1000) * i)),
hour = date.getHours().toString().padStart(2, '0'),
minutes = date.getMinutes().toString().padStart(2, '0');
headerDivs += `<div class="gfb-gantt-header">${hour}:${minutes}</div>`;
}
}
return `<div class="gfb-gantt-headers" style="grid-template-columns: 100px repeat(${this.divisionCount}, 1fr)">${headerDivs}</div>`;
}
buildLines() {
let lines = '<div class="gfb-gantt-sidebar-template"></div>';
for (let i = 0; i < this.divisionCount; i++) {
lines += `<div class="gfb-gantt-line"></div>`;
}
return `<div class="gfb-gantt-lines-container" style="grid-template-columns: 100px repeat(${this.divisionCount}, 1fr)">${lines}</div>`;
}
buildRow(rowArr, dataIndex) {
let totalTime = this.maxTime - this.minTime,
compositeRows = `<div style="grid-column: 2/${this.divisionCount + 1};grid-row:1;display:flex;align-items:center"><div class="gfb-gantt-sub-row-wrapper">`;
for (let i = 0; i < rowArr.length; i++) {
//Check to see if the current entry has a start and end time. If not we break
if (!rowArr[i][this.startTimeAlias] || !rowArr[i][this.startTimeAlias])
break;
let currElStart = new Date(rowArr[i][this.startTimeAlias]),
currElEnd = new Date(rowArr[i][this.endTimeAlias]),
currElRunPercent = ((currElEnd - currElStart) / totalTime) * 100;
if (i === 0 || (rowArr[i - 1] && new Date(rowArr[i - 1][this.endTimeAlias]) !== currElStart)) {
let baseTime = (i === 0 ? this.minTime : new Date(rowArr[i - 1][this.endTimeAlias])),
difference = ((currElStart - baseTime) / totalTime) * 100;
compositeRows += `<div style="width:${difference}%;"></div>`;
}
//If we don't have a linkAlias we assume the entries are not meant to link anywhere, so we just render them as divs instead.
if (this.linkAlias)
compositeRows += `<a class="gfb-gantt-row-entry" style="width:${currElRunPercent}%;" href="${rowArr[i][this.linkAlias]}" data-index="${dataIndex.join('-')}-${i}"></a>`;
else
compositeRows += `<div class="gfb-gantt-row-entry" style="width:${currElRunPercent}%;" data-index="${dataIndex.join('-')}-${i}"></div>`;
}
return compositeRows + '</div></div>';
}
buildContent() {
let body = ['<div class="gfb-gantt-row-container">'],
header = this.buildHeader(),
this1 = this;
buildContent(this.data, body);
body.push('</div>');
return `<div class="gfb-gantt-content">${header}${body.join('')}</div>`;
function buildContent(data, result, depth = 0, dataIndex = []) {
for (let prop in data) {
if (data[prop]) {
if(prop !== 'groupName')
dataIndex.push(prop);
if (typeof data[prop] === "object" && !Array.isArray(data[prop])) {
result.push(`<div class="gfb-gantt-grouping-header" style="padding-left: ${5 + (depth * 20)}px">${data[prop].groupName}</div><div>`);
depth += 1;
buildContent(data[prop], result, depth, dataIndex);
depth -= 1;
}
else if (Array.isArray(data[prop])) {
result.push(`<div class="gfb-gantt-row" style="grid-template-columns: 100px repeat(${this1.divisionCount}, 1fr)"><div class="gfb-gantt-sidebar-header">${data[prop][0][this1.rowAlias]}</div>${this1.buildRow(data[prop], dataIndex)}</div>`);
}
dataIndex.pop();
}
}
result.push('</div>');
}
}
buildChart() {
let content = this.buildContent();
let lines = this.buildLines();
return `${lines}${content}`;
}
bindHover() {
let bindElements = document.querySelectorAll(`#${this.staticID} .gfb-gantt-row-entry`);
bindElements.forEach(bindElement => {
let toolTipElement,
timeout;
bindElement.addEventListener('mouseover', e => {
let target = e.target,
indexArray = target.getAttribute('data-index').split('-'),
position = getElOffset(target),
targetHeight = getBoundingRect(target).height,
this1 = this;
indexArray.push(this.tooltipAlias);
position.top += (targetHeight + 5);
target.classList.add('hovering');
//Set a delay on the tooltip appearing so that it doesn't immediately appear if the user happens to swipe their mouse across the screen.
//At the end of the delay we check to see if the mouse is still hovering, at which point we will show the tooltip
timeout = setTimeout(() => {
if (!target.classList.contains('hovering'))
return;
toolTipElement = document.createElement(`div`);
toolTipElement.classList.add('gfb-gantt-row-entry-tooltip');
toolTipElement.innerHTML = getToolTipData();
toolTipElement.style.top = `${position.top}px`;
toolTipElement.style.left = `${position.left}px`;
document.body.appendChild(toolTipElement);
fadeIn(toolTipElement, 300);
}, 300);
function getToolTipData() {
let dataArray = this1.data;
for (let i = 0; i < indexArray.length; i++) {
if (i === indexArray.length - 1)
return dataArray[indexArray[i]]
else
dataArray = dataArray[indexArray[i]];
}
}
})
bindElement.addEventListener('mouseout', e => {
let target = e.target;
clearTimeout(timeout);
target.classList.remove('hovering');
if (toolTipElement)
fadeOut(toolTipElement, 300, () => toolTipElement.remove());
})
});
}
bindCollapse(){
let bindElements = document.querySelectorAll(`#${this.staticID} .gfb-gantt-grouping-header`);
bindElements.forEach(bindElement => {
bindElement.addEventListener('click', e => {
let group = e.target.nextSibling,
duration = 300;
if(!group.classList.contains('gfb-gantt-group--collapsed')){
slideUp(group, duration);
group.classList.add('gfb-gantt-group--collapsed');
}
else{
slideDown(group, duration);
group.classList.remove('gfb-gantt-group--collapsed');
}
})
})
}
render() {
this.wrapper.innerHTML = this.buildChart();
this.bindCollapse();
if (this.tooltipAlias)
this.bindHover();
}
noDataFound() {
this.wrapper.innerHTML = this.noDataFoundMessage;
}
empty() {
this.wrapper.innerHTML = '';
}
}
function roundHourUp(date) {
let m = 60 * 60 * 1000
return new Date(Math.ceil(date.getTime() / m) * m);
}
function roundHourDown(date) {
let m = 60 * 60 * 1000;
return new Date(Math.floor(date.getTime() / m) * m);
}
function fadeIn(element, ms, callback = null) {
let duration = ms,
interval = 50,
gap = interval / duration,
opacity = 0;
element.style.display = 'block';
element.style.opacity = opacity;
let fading = window.setInterval(fade, interval);
function fade() {
opacity = opacity + gap;
element.style.opacity = opacity;
if (opacity <= 0)
element.style.display = 'none';
if (opacity >= 1) {
window.clearInterval(fading);
if (callback)
callback();
}
}
}
function fadeOut(element, ms, callback = null) {
let duration = ms,
interval = 50,
gap = interval / duration,
opacity = 1;
let fading = window.setInterval(fade, interval);
function fade() {
opacity = opacity - gap;
element.style.opacity = opacity;
if (opacity <= 0)
element.style.display = 'none';
if (opacity <= 0) {
window.clearInterval(fading);
if (callback)
callback();
}
}
}
function getElOffset(element) {
let boundingRect = getBoundingRect(element);
return { top: boundingRect.top + window.scrollY, left: boundingRect.left + window.scrollX };
}
function getBoundingRect(element) {
return element.getBoundingClientRect();
}
function slideUp(element, ms){
setProperty(element, 'height', `${element.offsetHeight}px`);
setProperty(element, 'transition-property', 'height, margin, padding');
setProperty(element, 'transition-duration', `${ms}ms`);
setProperty(element, 'box-sizing', 'border-box');
zeroMultiProperty(element, ['margin-bottom', 'margin-top', 'padding-top', 'padding-bottom']);
setProperty(element, 'overflow', 'hidden');
//Need a timeout otherwise the slide up animation is not performed
setTimeout(() => setProperty(element, 'height'), 0);
setTimeout(() => {
setProperty(element, 'display', 'none');
removeMultiProperty(element, ['height', 'padding-top', 'padding-bottom', 'margin-top', 'margin-bottom', 'overflow', 'transition-duration', 'transition-property'])
}, ms);
}
function slideDown(element, ms){
element.style.display = 'block';
let height = element.offsetHeight;
setProperty(element, 'height');
setProperty(element, 'transition-property', 'height, margin, padding');
setProperty(element, 'transition-duration', `${ms}ms`);
setProperty(element, 'box-sizing', 'border-box');
setProperty(element, 'overflow', 'hidden');
//Need a timeout otherwise the slide up animation is not performed
setTimeout(() => setProperty(element, 'height', `${height}px`), 0);
setTimeout(() => {
removeMultiProperty(element, ['height', 'overflow', 'transition-duration', 'transition-property']);
}, ms);
}
//Sets the inline property of the given element to the value provided.
//If not provided, the value is set to 0
function setProperty(element, property, value = 0) {
element.style.setProperty(property, value);
}
//Iteratively calls setProperty
function zeroMultiProperty(element, properties) {
for (property of properties) {
setProperty(element, property);
}
}
//Iteratively calls removeProperty
function removeMultiProperty(element, properties){
for(property of properties)
removeProperty(element, property);
}
//Removes the given property from the given element's inline styling
function removeProperty(element, property){
element.style.removeProperty(property);
};