Converted Gantt from js to Jade.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2025-12-29 15:17:27 +02:00
parent ce9afbcaca
commit 2d3bef9033
11 changed files with 384 additions and 283 deletions

View file

@ -57,8 +57,9 @@ BlazeComponent.extendComponent({
BlazeComponent.extendComponent({
getComments() {
const ret = this.data().comments();
return ret;
const data = this.data();
if (!data || typeof data.comments !== 'function') return [];
return data.comments();
},
}).register("comments");

View file

@ -69,11 +69,4 @@ template(name="calendarView")
.calendar-view.swimlane
if currentCard
+cardDetails(currentCard)
+fullcalendar(calendarOptions)
template(name="ganttView")
if isViewGantt
.gantt-view.swimlane
if currentCard
+cardDetails(currentCard)
.gantt-container
#gantt-chart
+fullcalendar(calendarOptions)

View file

@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import '../gantt/gantt.js';
import { TAPi18n } from '/imports/i18n';
import dragscroll from '@wekanteam/dragscroll';
import { boardConverter } from '/client/lib/boardConverter';
@ -1436,268 +1437,4 @@ BlazeComponent.extendComponent({
* 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
// In desktop mode, add to openCards array to support multiple cards
const isMobile = Utils.getMobileMode();
if (!isMobile) {
const openCards = Session.get('openCards') || [];
if (!openCards.includes(cardId)) {
openCards.push(cardId);
Session.set('openCards', openCards);
}
}
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

@ -85,6 +85,7 @@ BlazeComponent.extendComponent({
isWatching() {
const card = this.currentData();
if (!card || typeof card.findWatcher !== 'function') return false;
return card.findWatcher(Meteor.userId());
},
@ -161,8 +162,9 @@ BlazeComponent.extendComponent({
* @return is the list id the current list id ?
*/
isCurrentListId(listId) {
const ret = this.data().listId == listId;
return ret;
const data = this.data();
if (!data || typeof data.listId === 'undefined') return false;
return data.listId == listId;
},
onRendered() {
@ -375,7 +377,7 @@ BlazeComponent.extendComponent({
const card = this.currentData() || this.data();
const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id;
const cardId = card && card._id;
if (boardId) {
// In desktop mode, remove from openCards array
const isMobile = Utils.getMobileMode();
@ -383,19 +385,18 @@ BlazeComponent.extendComponent({
const openCards = Session.get('openCards') || [];
const filtered = openCards.filter(id => id !== cardId);
Session.set('openCards', filtered);
// If this was the current card, clear it
if (Session.get('currentCard') === cardId) {
Session.set('currentCard', null);
}
// Don't navigate away in desktop mode - just close the card
return;
}
// Mobile mode: 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) {
@ -818,6 +819,7 @@ Template.editCardSortOrderForm.onRendered(function () {
Template.cardDetailsActionsPopup.helpers({
isWatching() {
if (!this || typeof this.findWatcher !== 'function') return false;
return this.findWatcher(Meteor.userId());
},

View file

@ -275,8 +275,8 @@ BlazeComponent.extendComponent({
Template.checklists.helpers({
checklists() {
const card = ReactiveCache.getCard(this.cardId);
const ret = card.checklists();
return ret;
if (!card || typeof card.checklists !== 'function') return [];
return card.checklists();
},
});

View file

@ -1,3 +1,28 @@
/* Gantt chart cell background colors for Received, Start, Due, End (matching cardDetails) */
.ganttview-received {
background-color: #dbdbdb !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-start {
background-color: #90ee90 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-due {
background-color: #ffd700 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-end {
background-color: #ffb3b3 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
/* Gantt View Styles */
.gantt-view {

View file

@ -0,0 +1,27 @@
//- Gantt Chart View Template
template(name="ganttView")
link(rel="stylesheet" href="/client/components/gantt/gantt.css")
link(rel="stylesheet" href="/client/components/gantt/ganttCard.css")
.gantt-view
h2 {{_ 'board-view-gantt'}}
if hasSelectedCard
+ganttCard
each weeks
table.gantt-table
thead
tr
th {{_ 'task'}} {{_ 'predicate-week'}} {{week}}
each weekDays this
th
| {{formattedDate .}} {{weekdayLabel .}}
tbody
each cardsInWeek this
tr(data-card-id="{{cardId .}}")
td.js-gantt-task-cell
a.js-gantt-card-title(href="#")
+viewer
| {{cardTitle .}}
each weekDays ..
td(class="{{cellClasses .. .}}" data-card-id="{{cardId ..}}" data-date-type="{{cellContentClass .. .}}")
| {{cellContent .. .}}

View file

@ -0,0 +1,217 @@
// Add click handler to ganttView for card titles
Template.ganttView.events({
'click .js-gantt-card-title'(event, template) {
event.preventDefault();
// Get card ID from the closest row's data attribute
const $row = template.$(event.currentTarget).closest('tr');
const cardId = $row.data('card-id');
if (cardId) {
template.selectedCardId.set(cardId);
}
},
});
import { Template } from 'meteor/templating';
// Blaze template helpers for ganttView
function 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 };
}
function startOfISOWeek(d) {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
date.setHours(0,0,0,0);
return date;
}
Template.ganttView.helpers({
weeks() {
const board = Utils.getCurrentBoard();
if (!board) return [];
const cards = Cards.find({ boardId: board._id }, { sort: { startAt: 1, dueAt: 1 } }).fetch();
const weeksMap = new Map();
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) });
}
}
});
});
return Array.from(weeksMap.values()).sort((a,b) => a.start - b.start);
},
weekDays(week) {
const weekStart = new Date(week.start);
return Array.from({length:7}, (_,i) => {
const d = new Date(weekStart);
d.setDate(d.getDate() + i);
d.setHours(0,0,0,0);
return d;
});
},
weekdayLabel(day) {
const weekdayKeys = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
return TAPi18n.__(weekdayKeys[day.getDay() === 0 ? 6 : day.getDay() - 1]);
},
formattedDate(day) {
const currentUser = ReactiveCache.getCurrentUser && ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
return formatDateByUserPreference(day, dateFormat, false);
},
cardsInWeek(week) {
const board = Utils.getCurrentBoard();
if (!board) return [];
const cards = Cards.find({ boardId: board._id }).fetch();
return cards.filter(card => {
return ['receivedAt','startAt','dueAt','endAt'].some(field => {
if (card[field]) {
const dt = new Date(card[field]);
const info = getISOWeekInfo(dt);
return info.week === week.week && info.year === week.year;
}
return false;
});
});
},
cardTitle(card) {
return card.title;
},
cardId(card) {
return card._id;
},
cardUrl(card) {
if (!card) return '#';
const board = ReactiveCache.getBoard(card.boardId);
if (!board) return '#';
return FlowRouter.path('card', {
boardId: card.boardId,
slug: board.slug,
cardId: card._id,
});
},
cellContentClass(card, day) {
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,
};
if (cardDates.receivedAt && cardDates.receivedAt.toDateString() === day.toDateString()) return 'ganttview-received';
if (cardDates.startAt && cardDates.startAt.toDateString() === day.toDateString()) return 'ganttview-start';
if (cardDates.dueAt && cardDates.dueAt.toDateString() === day.toDateString()) return 'ganttview-due';
if (cardDates.endAt && cardDates.endAt.toDateString() === day.toDateString()) return 'ganttview-end';
return '';
},
cellContent(card, day) {
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,
};
if (cardDates.receivedAt && cardDates.receivedAt.toDateString() === day.toDateString()) return '📥';
if (cardDates.startAt && cardDates.startAt.toDateString() === day.toDateString()) return '🚀';
if (cardDates.dueAt && cardDates.dueAt.toDateString() === day.toDateString()) return '⏰';
if (cardDates.endAt && cardDates.endAt.toDateString() === day.toDateString()) return '🏁';
return '';
},
isToday(day) {
const today = new Date();
return day.toDateString() === today.toDateString();
},
isWeekend(day) {
const idx = day.getDay();
return idx === 0 || idx === 6;
},
hasSelectedCard() {
return Template.instance().selectedCardId.get() !== null;
},
selectedCard() {
const cardId = Template.instance().selectedCardId.get();
return cardId ? ReactiveCache.getCard(cardId) : null;
},
cellClasses(card, day) {
// Get the base class from cellContentClass logic
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,
};
let classes = '';
if (cardDates.receivedAt && cardDates.receivedAt.toDateString() === day.toDateString()) classes = 'ganttview-received';
else if (cardDates.startAt && cardDates.startAt.toDateString() === day.toDateString()) classes = 'ganttview-start';
else if (cardDates.dueAt && cardDates.dueAt.toDateString() === day.toDateString()) classes = 'ganttview-due';
else if (cardDates.endAt && cardDates.endAt.toDateString() === day.toDateString()) classes = 'ganttview-end';
// Add conditional classes
const today = new Date();
if (day.toDateString() === today.toDateString()) classes += ' ganttview-today';
const idx = day.getDay();
if (idx === 0 || idx === 6) classes += ' ganttview-weekend';
if (classes.trim()) classes += ' js-gantt-date-icon';
return classes.trim();
}
});
Template.ganttView.onCreated(function() {
this.selectedCardId = new ReactiveVar(null);
// Provide properties expected by cardDetails component
this.showOverlay = new ReactiveVar(false);
this.mouseHasEnterCardDetails = false;
});
// Blaze onRendered logic for ganttView
Template.ganttView.onRendered(function() {
const self = this;
this.autorun(() => {
// If you have legacy imperative rendering, keep it here
if (typeof renderGanttChart === 'function') {
renderGanttChart();
}
});
// Add click handler for date cells (Received, Start, Due, End)
this.$('.gantt-table').on('click', '.js-gantt-date-icon', function(e) {
e.preventDefault();
e.stopPropagation();
const $cell = self.$(this);
const cardId = $cell.data('card-id');
let dateType = $cell.data('date-type');
// Remove 'ganttview-' prefix to match popup map
if (typeof dateType === 'string' && dateType.startsWith('ganttview-')) {
dateType = dateType.replace('ganttview-', '');
}
const popupMap = {
received: 'editCardReceivedDate',
start: 'editCardStartDate',
due: 'editCardDueDate',
end: 'editCardEndDate',
};
const popupName = popupMap[dateType];
if (!popupName || typeof Popup === 'undefined' || typeof Popup.open !== 'function') return;
const card = ReactiveCache.getCard(cardId);
if (!card) return;
const openFn = Popup.open(popupName);
openFn.call({ currentData: () => card }, e, { dataContextIfCurrentDataIsUndefined: card });
});
});
import markdownit from 'markdown-it';
import { TAPi18n } from '/imports/i18n';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { ReactiveCache } from '/imports/reactiveCache';
const md = markdownit({ breaks: true, linkify: true });

View file

@ -0,0 +1,47 @@
.gantt-card-wrapper {
background: white;
border: 1px solid #ddd;
border-radius: 5px;
margin: 1rem 0;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.gantt-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.gantt-card-header h3 {
margin: 0;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
background-color: #f0f0f0;
color: #333;
}
.gantt-card-content {
max-height: 400px;
overflow-y: auto;
}

View file

@ -0,0 +1,7 @@
template(name="ganttCard")
.gantt-card-wrapper
.gantt-card-header
h3 {{_ 'card-details'}}
button.js-close-gantt-card.close-button ×
.gantt-card-content
+cardDetails(selectedCard)

View file

@ -0,0 +1,45 @@
BlazeComponent.extendComponent({
onCreated() {
// Provide the expected parent component properties for cardDetails
this.showOverlay = new ReactiveVar(false);
this.mouseHasEnterCardDetails = false;
},
selectedCard() {
// Get the selected card from the parent ganttView template
const parentView = this.view.parentView;
if (parentView && parentView.templateInstance) {
const cardId = parentView.templateInstance().selectedCardId.get();
return cardId ? ReactiveCache.getCard(cardId) : null;
}
return null;
},
events() {
return [
{
'click .js-close-gantt-card'(event) {
// Find the parent ganttView template and clear the selected card
const parentView = this.view.parentView;
if (parentView && parentView.templateInstance) {
parentView.templateInstance().selectedCardId.set(null);
}
},
},
];
},
}).register('ganttCard');
// Add click handler to ganttView for card titles
Template.ganttView.events({
'click .js-gantt-card-title'(event, template) {
event.preventDefault();
// Get card ID from the closest row's data attribute
const $row = template.$(event.currentTarget).closest('tr');
const cardId = $row.data('card-id');
if (cardId) {
template.selectedCardId.set(cardId);
}
},
});