diff --git a/client/components/boards/boardBody.jade b/client/components/boards/boardBody.jade index d2118112b..8357c857a 100644 --- a/client/components/boards/boardBody.jade +++ b/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 \ No newline at end of file diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index b0af16e43..18ff7dc59 100644 --- a/client/components/boards/boardBody.js +++ b/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 = `

${TAPi18n.__('no-cards-in-gantt')}

`; + 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 += ''; + html += ''; + html += ''; + const taskHeader = `${TAPi18n.__('task')} ${TAPi18n.__('predicate-week')} ${weekInfo.week}`; + html += ``; + weekdayLabels.forEach((lbl, idx) => { + const formattedDate = formatDateByUserPreference(weekDates[idx], dateFormat, false); + html += ``; + }); + html += ''; + + // Rows: include cards that have any date in this week + html += ''; + 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 += ''; + html += ``; + + // 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 += ``; + }); + + // Close row + html += ''; + }); + + // Close section for this week + html += '
${taskHeader}${formattedDate} ${lbl}
${card.title}${cellContent}
'; + }); + + 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'); diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index bac4216ed..1129282b3 100644 --- a/client/components/boards/boardHeader.jade +++ b/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 diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index c84b593c6..292a6b042 100644 --- a/client/components/boards/boardHeader.js +++ b/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({ diff --git a/client/components/boards/gantt.css b/client/components/boards/gantt.css new file mode 100644 index 000000000..6a14f4a3b --- /dev/null +++ b/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; + } +} diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 43ee28473..e0854d3f7 100644 --- a/client/components/cards/cardDetails.js +++ b/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(); diff --git a/client/lib/popup.js b/client/lib/popup.js index 6825f7032..c0bfb779f 100644 --- a/client/lib/popup.js +++ b/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(); diff --git a/client/lib/utils.js b/client/lib/utils.js index 078dfe967..ad64a4057 100644 --- a/client/lib/utils.js +++ b/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(); diff --git a/docs/Features/Gantt.md b/docs/Features/Gantt.md index de342e5b8..2881a1b80 100644 --- a/docs/Features/Gantt.md +++ b/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 diff --git a/models/users.js b/models/users.js index 3885638d1..e30689359 100644 --- a/models/users.js +++ b/models/users.js @@ -393,6 +393,7 @@ Users.attachSchema( 'board-view-swimlanes', 'board-view-lists', 'board-view-cal', + 'board-view-gantt', ], }, 'profile.listSortBy': {