|
@@ -5,6 +5,7 @@ import { boardConverter } from '/client/lib/boardConverter';
|
|
|
import { migrationManager } from '/client/lib/migrationManager';
|
|
import { migrationManager } from '/client/lib/migrationManager';
|
|
|
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
|
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
|
|
import { migrationProgressManager } from '/client/components/migrationProgress';
|
|
import { migrationProgressManager } from '/client/components/migrationProgress';
|
|
|
|
|
+import { formatDateByUserPreference } from '/imports/lib/dateUtils';
|
|
|
import Swimlanes from '/models/swimlanes';
|
|
import Swimlanes from '/models/swimlanes';
|
|
|
import Lists from '/models/lists';
|
|
import Lists from '/models/lists';
|
|
|
|
|
|
|
@@ -978,6 +979,19 @@ BlazeComponent.extendComponent({
|
|
|
return boardView === 'board-view-cal';
|
|
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() {
|
|
hasSwimlanes() {
|
|
|
const currentBoard = Utils.getCurrentBoard();
|
|
const currentBoard = Utils.getCurrentBoard();
|
|
|
if (!currentBoard) {
|
|
if (!currentBoard) {
|
|
@@ -1408,3 +1422,263 @@ BlazeComponent.extendComponent({
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
}).register('calendarView');
|
|
}).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');
|