Browse Source

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

Thanks to xet7 !

Fixes #2870
Lauri Ojansivu 22 hours ago
parent
commit
f34e4c0e36

+ 9 - 0
client/components/boards/boardBody.jade

@@ -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

+ 274 - 0
client/components/boards/boardBody.js

@@ -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');

+ 9 - 0
client/components/boards/boardHeader.jade

@@ -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

+ 4 - 0
client/components/boards/boardHeader.js

@@ -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({

+ 178 - 0
client/components/boards/gantt.css

@@ -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;
+  }
+}

+ 17 - 1
client/components/cards/cardDetails.js

@@ -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();

+ 5 - 0
client/lib/popup.js

@@ -212,6 +212,11 @@ window.Popup = new (class {
 
       if (Utils.isMiniScreen()) return { left: 0, top: 0 };
 
+      // If the opener element is missing (e.g., programmatic open), fallback to viewport origin
+      if (!$element || $element.length === 0) {
+        return { left: 10, top: 10, maxHeight: $(window).height() - 20 };
+      }
+
       const offset = $element.offset();
       // Calculate actual popup width based on CSS: min(380px, 55vw)
       const viewportWidth = $(window).width();

+ 5 - 0
client/lib/utils.js

@@ -264,6 +264,9 @@ Utils = {
     } else if (view === 'board-view-cal') {
       window.localStorage.setItem('boardView', 'board-view-cal'); //true
       Utils.reload();
+    } else if (view === 'board-view-gantt') {
+      window.localStorage.setItem('boardView', 'board-view-gantt'); //true
+      Utils.reload();
     } else {
       window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
       Utils.reload();
@@ -289,6 +292,8 @@ Utils = {
       return 'board-view-lists';
     } else if (window.localStorage.getItem('boardView') === 'board-view-cal') {
       return 'board-view-cal';
+    } else if (window.localStorage.getItem('boardView') === 'board-view-gantt') {
+      return 'board-view-gantt';
     } else {
       window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
       Utils.reload();

+ 9 - 111
docs/Features/Gantt.md

@@ -1,122 +1,20 @@
-# What is this?
+# Gantt chart
 
-Original WeKan is MIT-licensed software.
+This new Gantt feature was added to MIT WeKan 2025-12-22 at https://github.com/wekan/wekan
 
-This different Gantt version here currently uses Gantt chart component that has GPL license, so this Wekan Gantt version is GPL licensed.
+At "All Boards" page, click board to open one board view. There, Gantt is at top dropdown menu Swimlanes/Lists/Calendar/Gantt.
 
-Sometime later if that GPL licensed Gantt chart component will be changed to MIT licensed one, then that original MIT-licensed WeKan will get Gantt feature, and maybe this GPL version will be discontinued.
+Gantt shows all dates, according to selected date format at opened card: Received Start Due End.
 
-# How to use
+Gantt dates are shown for every week where exist dates at the current opened board.
 
-[Source](https://github.com/wekan/wekan/issues/2870#issuecomment-721690105)
+You can click task name to open card.
 
-At cards, both Start and End dates should be set (not Due date) for the tasks to be displayed.
+You can click any date icon to change that date, like: Received Start Due End.
 
-# Funding for more features?
+# Old WeKan Gantt GPL
 
-You can fund development of more features of Gantt at https://wekan.fi/commercial-support, like for example:
-- more of day/week/month/year views
-- drag etc
-
-# Issue
-
-https://github.com/wekan/wekan/issues/2870
-
-# Install
-
-Wekan GPLv2 Gantt version:
-- https://github.com/wekan/wekan-gantt-gpl
-- https://snapcraft.io/wekan-gantt-gpl
-- https://hub.docker.com/repository/docker/wekanteam/wekan-gantt-gpl
-- https://quay.io/wekan/wekan-gantt-gpl
-
-## How to install Snap
-
-[Like Snap install](https://github.com/wekan/wekan-snap/wiki/Install) but with commands like:
-```
-sudo snap install wekan-gantt-gpl
-
-sudo snap set wekan-gantt-gpl root-url='http://localhost'
-
-sudo snap set wekan-gantt-gpl port='80'
-```
-Stopping all:
-```
-sudo snap stop wekan-gantt-gpl
-```
-Stopping only some part:
-```
-sudo snap stop wekan-gantt-gpl.caddy
-
-sudo snap stop wekan-gantt-gpl.mongodb
-
-sudo snap stop wekan-gantt-gpl.wekan
-```
-
-## Changing from Wekan to Wekan Gantt GPL
-
-1) Install newest MongoDB to have also mongorestore available
-
-2) Backup database and settings:
-```
-sudo snap stop wekan.wekan
-
-mongodump --port 27019
-
-snap get wekan > snap-set.sh
-
-sudo snap remove wekan
-
-sudo snap install wekan-gantt-gpl
-
-sudo snap stop wekan-gantt-gpl.wekan
-
-nano snap-set.sh
-```
-Then edit that textfile so all commands will be similar to this:
-```
-sudo snap set wekan-gantt-gpl root-url='https://example.com'
-```
-And run settings:
-```
-chmod +x snap-set.sh
-
-./snap-set.sh
-
-sudo snap start wekan-gantt-gpl.wekan
-```
-## Changing from Wekan Gantt GPL to Wekan
-
-1) Install newest MongoDB to have also mongorestore available
-
-2) Backup database and settings:
-```
-sudo snap stop wekan-gantt-gpl.wekan
-
-mongodump --port 27019
-
-snap get wekan-gantt-gpl > snap-set.sh
-
-sudo snap remove wekan-gantt-gpl
-
-sudo snap install wekan
-
-sudo snap stop wekan.wekan
-
-nano snap-set.sh
-```
-Then edit that textfile so all commands will be similar to this:
-```
-sudo snap set wekan root-url='https://example.com'
-```
-And run settings:
-```
-chmod +x snap-set.sh
-
-./snap-set.sh
-
-sudo snap start wekan.wekan
-```
+Previous GPLv2 WeKan Gantt is deprecated https://github.com/wekan/wekan-gantt-gpl
 
 # UCS
 

+ 1 - 0
models/users.js

@@ -393,6 +393,7 @@ Users.attachSchema(
         'board-view-swimlanes',
         'board-view-lists',
         'board-view-cal',
+        'board-view-gantt',
       ],
     },
     'profile.listSortBy': {