Gantt chart view to one board view menu Swimlanes/Lists/Calendar/Gantt.

Thanks to xet7 !

Fixes #2870
This commit is contained in:
Lauri Ojansivu 2025-12-22 16:51:10 +02:00
parent 1790918006
commit f34e4c0e36
10 changed files with 511 additions and 112 deletions

View file

@ -49,6 +49,8 @@ template(name="boardBody")
+listsGroup(currentBoard)
else if isViewCalendar
+calendarView
else if isViewGantt
+ganttView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
@ -64,3 +66,10 @@ template(name="calendarView")
if currentCard
+cardDetails(currentCard)
+fullcalendar(calendarOptions)
template(name="ganttView")
if isViewGantt
.gantt-view.swimlane
if currentCard
+cardDetails(currentCard)
.gantt-container
#gantt-chart

View file

@ -5,6 +5,7 @@ import { boardConverter } from '/client/lib/boardConverter';
import { migrationManager } from '/client/lib/migrationManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import { migrationProgressManager } from '/client/components/migrationProgress';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
@ -978,6 +979,19 @@ BlazeComponent.extendComponent({
return boardView === 'board-view-cal';
},
isViewGantt() {
const currentUser = ReactiveCache.getCurrentUser();
let boardView;
if (currentUser) {
boardView = (currentUser.profile || {}).boardView;
} else {
boardView = window.localStorage.getItem('boardView');
}
return boardView === 'board-view-gantt';
},
hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) {
@ -1408,3 +1422,263 @@ BlazeComponent.extendComponent({
}
},
}).register('calendarView');
/**
* Gantt View Component
* Displays cards as a Gantt chart with start/due dates
*/
BlazeComponent.extendComponent({
template() {
return 'ganttView';
},
onCreated() {
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board) {
// Subscribe to cards for the current board
this.subscribe('allCards', board._id);
this.subscribe('allLists', board._id);
}
});
},
onRendered() {
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board && this.subscriptionsReady()) {
this.renderGanttChart();
}
});
},
renderGanttChart() {
const board = Utils.getCurrentBoard();
if (!board) return;
const ganttContainer = document.getElementById('gantt-chart');
if (!ganttContainer) return;
// Clear previous content
ganttContainer.innerHTML = '';
// Get all cards for the board
const cards = Cards.find({ boardId: board._id }, { sort: { startAt: 1, dueAt: 1 } }).fetch();
if (cards.length === 0) {
ganttContainer.innerHTML = `<p style="padding: 20px; text-align: center; color: #999;">${TAPi18n.__('no-cards-in-gantt')}</p>`;
return;
}
// Create a weekly HTML gantt view
this.createWeeklyGanttView(cards, ganttContainer);
},
createWeeklyGanttView(cards, container) {
const today = new Date();
const currentUser = ReactiveCache.getCurrentUser && ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
// Helpers to compute ISO week and start/end of week
const getISOWeekInfo = d => {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
return { year: date.getUTCFullYear(), week };
};
const startOfISOWeek = d => {
const date = new Date(d);
const day = date.getDay() || 7; // Sunday -> 7
if (day !== 1) date.setDate(date.getDate() - (day - 1));
date.setHours(0,0,0,0);
return date;
};
// Collect weeks that have any dates on cards
const weeksMap = new Map(); // key: `${year}-W${week}` -> { year, week, start }
const relevantCards = cards.filter(c => c.receivedAt || c.startAt || c.dueAt || c.endAt);
relevantCards.forEach(card => {
['receivedAt','startAt','dueAt','endAt'].forEach(field => {
if (card[field]) {
const dt = new Date(card[field]);
const info = getISOWeekInfo(dt);
const key = `${info.year}-W${info.week}`;
if (!weeksMap.has(key)) {
weeksMap.set(key, { year: info.year, week: info.week, start: startOfISOWeek(dt) });
}
}
});
});
// Sort weeks by start ascending (oldest first)
const weeks = Array.from(weeksMap.values()).sort((a,b) => a.start - b.start);
// Weekday labels
const weekdayKeys = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
const weekdayLabels = weekdayKeys.map(k => TAPi18n.__(k));
// Build HTML for all week tables
let html = '';
weeks.forEach(weekInfo => {
const weekStart = new Date(weekInfo.start);
const weekDates = Array.from({length:7}, (_,i) => {
const d = new Date(weekStart);
d.setDate(d.getDate() + i);
d.setHours(0,0,0,0);
return d;
});
// Table header
html += '<table class="gantt-table">';
html += '<thead>';
html += '<tr>';
const taskHeader = `${TAPi18n.__('task')} ${TAPi18n.__('predicate-week')} ${weekInfo.week}`;
html += `<th>${taskHeader}</th>`;
weekdayLabels.forEach((lbl, idx) => {
const formattedDate = formatDateByUserPreference(weekDates[idx], dateFormat, false);
html += `<th>${formattedDate} ${lbl}</th>`;
});
html += '</tr></thead>';
// Rows: include cards that have any date in this week
html += '<tbody>';
relevantCards.forEach(card => {
const cardDates = {
receivedAt: card.receivedAt ? new Date(card.receivedAt) : null,
startAt: card.startAt ? new Date(card.startAt) : null,
dueAt: card.dueAt ? new Date(card.dueAt) : null,
endAt: card.endAt ? new Date(card.endAt) : null,
};
const isInWeek = Object.values(cardDates).some(dt => dt && getISOWeekInfo(dt).week === weekInfo.week && getISOWeekInfo(dt).year === weekInfo.year);
if (!isInWeek) return;
// Row header cell (task title)
html += '<tr>';
html += `<td class="js-gantt-task-cell" data-card-id="${card._id}" title="${card.title}">${card.title}</td>`;
// Weekday cells with icons/colors only on exact matching dates
weekDates.forEach((dayDate, idx) => {
let cellContent = '';
let cellClass = '';
let cellStyle = '';
let cellTitle = '';
let cellDateType = '';
// Highlight today and weekends
const isToday = dayDate.toDateString() === today.toDateString();
if (isToday) {
cellClass += ' ganttview-today';
cellStyle += 'background-color: #fcf8e3 !important;';
}
const isWeekend = idx >= 5; // Saturday/Sunday
if (isWeekend) {
cellClass += ' ganttview-weekend';
if (!isToday) cellStyle += 'background-color: #efefef !important;';
}
// Match specific date types
if (cardDates.receivedAt && cardDates.receivedAt.toDateString() === dayDate.toDateString()) {
cellContent = '📥';
cellStyle = 'background-color: #dbdbdb !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-received');
cellDateType = 'received';
}
if (cardDates.startAt && cardDates.startAt.toDateString() === dayDate.toDateString()) {
cellContent = '🚀';
cellStyle = 'background-color: #90ee90 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-start');
cellDateType = 'start';
}
if (cardDates.dueAt && cardDates.dueAt.toDateString() === dayDate.toDateString()) {
cellContent = '⏰';
cellStyle = 'background-color: #ffd700 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-due');
cellDateType = 'due';
}
if (cardDates.endAt && cardDates.endAt.toDateString() === dayDate.toDateString()) {
cellContent = '🏁';
cellStyle = 'background-color: #ffb3b3 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-end');
cellDateType = 'end';
}
if (cellDateType) {
cellClass += ' js-gantt-date-icon';
}
const cellDataAttrs = cellDateType ? ` data-card-id="${card._id}" data-date-type="${cellDateType}"` : '';
html += `<td class="${cellClass}" style="${cellStyle}" title="${cellTitle}"${cellDataAttrs}>${cellContent}</td>`;
});
// Close row
html += '</tr>';
});
// Close section for this week
html += '</tbody></table>';
});
container.innerHTML = html;
// Add click handlers
const taskCells = container.querySelectorAll('.js-gantt-task-cell');
taskCells.forEach(cell => {
cell.addEventListener('click', (e) => {
const cardId = e.currentTarget.dataset.cardId;
const card = ReactiveCache.getCard(cardId);
if (!card) return;
// Scroll the gantt container and viewport to top so the card details are visible
if (container && typeof container.scrollIntoView === 'function') {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
const contentEl = document.getElementById('content');
if (contentEl && typeof contentEl.scrollTo === 'function') {
contentEl.scrollTo({ top: 0, behavior: 'smooth' });
}
// Open card the same way as clicking a minicard - set currentCard session
// This shows the full card details overlay, not a popup
Session.set('currentCard', cardId);
});
});
// Date icon click handlers: open the same edit popups as in swimlane cards
const dateIconCells = container.querySelectorAll('.js-gantt-date-icon');
dateIconCells.forEach(cell => {
cell.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const cardId = e.currentTarget.dataset.cardId;
const dateType = e.currentTarget.dataset.dateType;
const card = ReactiveCache.getCard(cardId);
if (!card || !dateType) return;
const popupMap = {
received: 'editCardReceivedDate',
start: 'editCardStartDate',
due: 'editCardDueDate',
end: 'editCardEndDate',
};
const popupName = popupMap[dateType];
if (!popupName || !Popup || typeof Popup.open !== 'function') return;
const openFn = Popup.open(popupName);
// Supply the card as data context for the popup
openFn.call({ currentData: () => card }, e, { dataContextIfCurrentDataIsUndefined: card });
});
});
},
isViewGantt() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
return (currentUser.profile || {}).boardView === 'board-view-gantt';
} else {
return window.localStorage.getItem('boardView') === 'board-view-gantt';
}
},
}).register('ganttView');

View file

@ -121,6 +121,8 @@ template(name="boardHeaderBar")
| 📋
if $eq boardView 'board-view-cal'
| 📅
if $eq boardView 'board-view-gantt'
| 📊
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
@ -208,6 +210,13 @@ template(name="boardChangeViewPopup")
| {{_ 'board-view-cal'}}
if $eq Utils.boardView "board-view-cal"
| ✅
li
with "board-view-gantt"
a.js-open-gantt-view
| 📊
| {{_ 'board-view-gantt'}}
if $eq Utils.boardView "board-view-gantt"
| ✅
template(name="createBoard")
form

View file

@ -208,6 +208,10 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-cal');
Popup.back();
},
'click .js-open-gantt-view'() {
Utils.setBoardView('board-view-gantt');
Popup.back();
},
});
const CreateBoard = BlazeComponent.extendComponent({

View file

@ -0,0 +1,178 @@
/* Gantt View Styles */
.gantt-view {
width: 100%;
height: auto;
overflow: visible;
background-color: #fff;
}
.gantt-view.swimlane {
background-color: #fff;
padding: 10px;
}
.gantt-container {
overflow-x: auto;
overflow-y: visible;
background-color: #fff;
display: block;
width: 100%;
}
.gantt-container table,
.gantt-table {
border-collapse: collapse;
width: 100%;
min-width: 800px;
border: 2px solid #666;
font-family: sans-serif;
font-size: 13px;
background-color: #fff;
}
.gantt-container thead {
background-color: #e8e8e8;
border-bottom: 2px solid #666;
font-weight: bold;
position: sticky;
top: 0;
z-index: 10;
}
.gantt-container thead th,
.gantt-container thead tr > td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
width: 100px; /* half of 200px */
text-align: left;
font-weight: bold;
background-color: #e8e8e8;
min-width: 100px; /* half of 200px */
}
.gantt-container thead td {
border-right: 1px solid #999;
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
font-size: 11px;
min-width: 15px; /* half of 30px */
font-weight: bold;
height: auto;
line-height: 1.2;
white-space: normal;
word-break: break-word;
}
.gantt-container tbody tr {
border-bottom: 1px solid #999;
height: 32px;
}
.gantt-container tbody tr:hover {
background-color: #f9f9f9;
}
.gantt-container tbody tr:hover td {
background-color: #f9f9f9 !important;
}
.gantt-container tbody td {
border-right: 1px solid #ccc;
padding: 1px; /* half */
text-align: center;
min-width: 15px; /* half of 30px */
height: 32px;
vertical-align: middle;
line-height: 28px;
background-color: #ffffff;
font-size: 18px;
font-weight: bold;
}
.gantt-container tbody td:nth-child(even) {
background-color: #fafafa;
}
.gantt-container tbody td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
font-weight: 500;
cursor: pointer;
background-color: #fafafa !important;
text-align: left;
width: 100px; /* half of 200px */
min-width: 100px; /* half of 200px */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: auto;
line-height: normal;
}
.gantt-container tbody td:first-child:hover {
background-color: #f0f0f0 !important;
text-decoration: underline;
}
.js-gantt-task-cell {
cursor: pointer;
}
.js-gantt-date-icon {
cursor: pointer;
}
.gantt-container .ganttview-weekend {
background-color: #efefef;
}
.gantt-container .ganttview-today {
background-color: #fcf8e3;
border-right: 2px solid #ffb347;
}
/* Task bar styling - VERY VISIBLE */
.gantt-container tbody td.ganttview-block {
background-color: #4CAF50 !important;
color: #fff !important;
font-size: 18px !important;
font-weight: bold !important;
padding: 2px !important;
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.gantt-container table {
font-size: 11px;
}
.gantt-container thead td {
min-width: 20px;
padding: 2px;
}
.gantt-container tbody td {
min-width: 20px;
padding: 1px;
height: 20px;
}
.gantt-container tbody td:first-child {
width: 100px;
font-size: 12px;
}
}
/* Print styles */
@media print {
.gantt-container {
overflow: visible;
}
.gantt-container table {
page-break-inside: avoid;
}
}

View file

@ -297,7 +297,23 @@ BlazeComponent.extendComponent({
{
...events,
'click .js-close-card-details'() {
Utils.goBoardId(this.data().boardId);
// Get board ID from either the card data or current board in session
const card = this.currentData() || this.data();
const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id;
if (boardId) {
// Clear the current card session to close the card
Session.set('currentCard', null);
// Navigate back to board without card
const board = ReactiveCache.getBoard(boardId);
if (board) {
FlowRouter.go('board', {
id: board._id,
slug: board.slug,
});
}
}
},
'click .js-copy-link'(event) {
event.preventDefault();