feat(kanban,tasks): multi-select filters and Kanban toolbar fixes (#464)

- Tasks list: add form_id to Project/Assigned To multi-select so Apply triggers AJAX filter refresh
- Tasks export: support project_ids and assigned_to_ids (multi-select) using same parse_ids logic as list view
- Page header: overflow-visible and z-20 so Kanban filter dropdowns appear above the board
- Kanban toolbar: align Add task/Manage Columns with dropdowns (items-end) and consistent button styling

fix(calendar): entry click opens modal near item, time entries link to /timer/edit/ (#475)

- Open popup modal with basic details and 'Go to all details' for all entry types (time entry, event, task) in both timer and custom calendar
- Position modal near the clicked item instead of centered
- Ensure time entries (registered time) always navigate to /timer/edit/: infer type from item_type, type, and props so wrong item_type does not send users to /calendar/event/ (404)
- Make time entries clickable in custom calendar day view (remove pointer-events: none)
- Timer calendar: show correct detail URL and modal title per type; hide Delete/Duplicate for non-time-entry types

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dries Peeters
2026-01-31 07:46:55 +01:00
parent 0480b1d1c5
commit 560bb0aec8
8 changed files with 438 additions and 125 deletions

View File

@@ -1139,12 +1139,24 @@ def bulk_move_project():
@tasks_bp.route("/tasks/export")
@login_required
def export_tasks():
"""Export tasks to CSV"""
# Get the same filters as the list view
"""Export tasks to CSV (supports same filters as list view, including multi-select)"""
# Parse filter parameters - same logic as list_tasks (multi-select + backward compat)
def parse_ids(param_name):
multi_param = request.args.get(param_name + "s", "").strip()
if multi_param:
try:
return [int(x.strip()) for x in multi_param.split(",") if x.strip()]
except (ValueError, AttributeError):
return []
single_param = request.args.get(param_name, type=int)
if single_param:
return [single_param]
return []
status = request.args.get("status", "")
priority = request.args.get("priority", "")
project_id = request.args.get("project_id", type=int)
assigned_to = request.args.get("assigned_to", type=int)
project_ids = parse_ids("project_id")
assigned_to_ids = parse_ids("assigned_to")
search = request.args.get("search", "").strip()
overdue_param = request.args.get("overdue", "").strip().lower()
overdue = overdue_param in ["1", "true", "on", "yes"]
@@ -1158,11 +1170,11 @@ def export_tasks():
if priority:
query = query.filter_by(priority=priority)
if project_id:
query = query.filter_by(project_id=project_id)
if project_ids:
query = query.filter(Task.project_id.in_(project_ids))
if assigned_to:
query = query.filter_by(assigned_to=assigned_to)
if assigned_to_ids:
query = query.filter(Task.assigned_to.in_(assigned_to_ids))
if search:
like = f"%{search}%"

View File

@@ -77,8 +77,7 @@
border-left-color: #10b981;
background-color: #ecfdf5;
opacity: 0.9;
cursor: default;
pointer-events: none;
cursor: pointer;
}
.event-card.time_entry::before {
@@ -457,6 +456,37 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.modal-header .modal-title {
margin: 0;
flex: 1;
}
.modal-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.event-detail-row {
margin-bottom: 0.75rem;
}
.event-detail-row:last-child {
margin-bottom: 0;
}
.event-detail-label {
font-size: 0.875rem;
color: var(--text-muted, #6b7280);
margin-bottom: 0.25rem;
}
.event-detail-value {
font-size: 1rem;
}
.modal-title {

View File

@@ -59,10 +59,19 @@ class Calendar {
this.render();
});
// Save calendar colors
document.getElementById('saveCalendarColorsBtn')?.addEventListener('click', () => {
this.saveCalendarColors();
});
// Modal close
document.querySelectorAll('[data-dismiss="modal"]').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('eventModal').style.display = 'none';
const eventModal = document.getElementById('eventModal');
if (eventModal) {
eventModal.style.display = 'none';
eventModal.classList.remove('show');
}
});
});
}
@@ -117,20 +126,24 @@ class Calendar {
const rawEvents = data.events || [];
const rawTasks = data.tasks || [];
const rawTimeEntries = data.time_entries || [];
const typeColors = data.typeColors || (window.calendarData && window.calendarData.typeColors) || { event: '#3b82f6', task: '#f59e0b', time_entry: '#10b981' };
this.events = rawEvents.map(e => ({
...e,
extendedProps: { ...e, item_type: 'event' }
color: e.color != null ? e.color : typeColors.event,
extendedProps: { ...(e.extendedProps || {}), ...e, item_type: (e.extendedProps && e.extendedProps.item_type) || 'event' }
}));
this.tasks = rawTasks.map(t => ({
id: t.id,
title: t.title,
start: t.dueDate,
end: t.dueDate,
color: t.color != null ? t.color : typeColors.task,
extendedProps: { ...t, item_type: 'task' }
}));
this.timeEntries = rawTimeEntries.map(e => ({
...e,
extendedProps: { ...e, item_type: 'time_entry' }
color: e.color != null ? e.color : typeColors.time_entry,
extendedProps: { ...(e.extendedProps || {}), ...e, item_type: (e.extendedProps && e.extendedProps.item_type) || 'time_entry' }
}));
console.log('API Response:', {
@@ -143,12 +156,54 @@ class Calendar {
});
this.render();
// Update color inputs and legend from API typeColors if present
if (data.typeColors) {
const eventsInput = document.getElementById('calendarColorEvents');
const tasksInput = document.getElementById('calendarColorTasks');
const entriesInput = document.getElementById('calendarColorTimeEntries');
if (eventsInput) eventsInput.value = data.typeColors.event || eventsInput.value;
if (tasksInput) tasksInput.value = data.typeColors.task || tasksInput.value;
if (entriesInput) entriesInput.value = data.typeColors.time_entry || entriesInput.value;
}
} catch (error) {
console.error('Error loading events:', error);
this.container.innerHTML = '<div class="text-center text-red-500 py-12">Error loading calendar data</div>';
}
}
async saveCalendarColors() {
const prefsUrl = window.calendarData?.preferencesUrl;
const csrfToken = window.calendarData?.csrfToken;
if (!prefsUrl || !csrfToken) return;
const eventsInput = document.getElementById('calendarColorEvents');
const tasksInput = document.getElementById('calendarColorTasks');
const entriesInput = document.getElementById('calendarColorTimeEntries');
const payload = {
calendar_color_events: eventsInput?.value || null,
calendar_color_tasks: tasksInput?.value || null,
calendar_color_time_entries: entriesInput?.value || null
};
try {
const resp = await fetch(prefsUrl, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (resp.ok && data.success) {
this.loadEvents();
} else {
alert(data.error || 'Failed to save calendar colors');
}
} catch (e) {
console.error('Save calendar colors failed', e);
alert('Failed to save calendar colors');
}
}
getDateRange() {
let start, end;
@@ -372,6 +427,7 @@ class Calendar {
const endTimeStr = entryEnd ? effectiveEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
const notesText = entry.notes || entry.extendedProps?.notes || '';
const notes = notesText ? `<br><small class="text-xs">${this.escapeHtml(notesText)}</small>` : '';
const entryColor = entry.color || '#10b981';
timedItems.push({
type: 'time_entry',
startMs: effectiveStart.getTime(),
@@ -383,7 +439,8 @@ class Calendar {
endTimeStr,
entryEnd,
title: this.escapeHtml(entry.title),
notes
notes,
color: entryColor
});
});
}
@@ -399,7 +456,7 @@ class Calendar {
html += `
<div class="event-card event" data-id="${item.event.id}" data-type="event"
style="border-left-color: ${item.color}; ${style}"
onclick="window.calendar.showEventDetails(${item.event.id}, 'event')">
onclick="window.calendar.showEventDetails(${item.event.id}, 'event', event)">
<i class="fas fa-calendar mr-2 text-blue-600 dark:text-blue-400"></i>
<strong>${item.title}</strong>
<br><small>${item.time}</small>
@@ -409,7 +466,8 @@ class Calendar {
const durationText = item.entryEnd ? `${item.startTime} - ${item.endTimeStr}` : `${item.startTime} (active)`;
html += `
<div class="event-card time_entry" data-id="${item.entry.id}" data-type="time_entry"
style="${style}">
style="border-left-color: ${item.color}; ${style}"
onclick="window.calendar.showEventDetails(${item.entry.id}, 'time_entry', event)">
⏱ <strong>${item.title}</strong>
<br><small>${durationText}</small>
${item.notes}
@@ -421,10 +479,11 @@ class Calendar {
if (this.showTasks) {
this.tasks.forEach(task => {
const taskTitle = this.escapeHtml(task.title);
const taskColor = task.color || '#f59e0b';
const priorityIcons = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
const priorityIcon = priorityIcons[task.extendedProps?.priority] || '📋';
html += `
<div class="event-card task" data-id="${task.id}" data-type="task" onclick="window.open('/tasks/${task.id}', '_blank')">
<div class="event-card task" data-id="${task.id}" data-type="task" style="border-left-color: ${taskColor};" onclick="window.calendar.showEventDetails(${task.id}, 'task', event)">
${priorityIcon} <strong>${taskTitle}</strong>
<br><small>Due: ${task.start}</small>
<br><small class="text-xs">Status: ${task.extendedProps?.status || 'Unknown'}</small>
@@ -520,6 +579,7 @@ class Calendar {
}
const startTime = effectiveStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const endTimeStr = entryEnd ? effectiveEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
const entryColor = entry.color || '#10b981';
timedItems.push({
type: 'time_entry',
startMs: effectiveStart.getTime(),
@@ -530,7 +590,8 @@ class Calendar {
title: this.escapeHtml(entry.title),
startTime,
endTimeStr,
entryEnd
entryEnd,
color: entryColor
});
});
}
@@ -539,6 +600,7 @@ class Calendar {
const taskDate = new Date(task.start);
if (taskDate.toDateString() !== day.toDateString()) return;
const dueMinutes = 9 * 60;
const taskColor = task.color || '#f59e0b';
timedItems.push({
type: 'task',
startMs: dayStart.getTime() + dueMinutes * 60 * 1000,
@@ -546,7 +608,8 @@ class Calendar {
task,
topPosition: dueMinutes,
heightMinutes: 30,
title: this.escapeHtml(task.title)
title: this.escapeHtml(task.title),
color: taskColor
});
});
}
@@ -556,12 +619,12 @@ class Calendar {
const { left, width } = this.columnStyle(item.column, item.totalColumns);
const style = `left: ${left}; width: ${width}; top: ${item.topPosition}px; height: ${item.heightMinutes}px;`;
if (item.type === 'event') {
html += `<div class="week-event-block event" data-id="${item.event.id}" data-type="event" style="border-left-color: ${item.color}; ${style}" onclick="window.calendar.showEventDetails(${item.event.id}, 'event')" title="${item.title} (${item.timeStr})"><i class="fas fa-calendar mr-1"></i><strong>${item.title}</strong><br><small>${item.timeStr}</small></div>`;
html += `<div class="week-event-block event" data-id="${item.event.id}" data-type="event" style="border-left-color: ${item.color}; ${style}" onclick="window.calendar.showEventDetails(${item.event.id}, 'event', event)" title="${item.title} (${item.timeStr})"><i class="fas fa-calendar mr-1"></i><strong>${item.title}</strong><br><small>${item.timeStr}</small></div>`;
} else if (item.type === 'time_entry') {
const durationText = item.entryEnd ? `${item.startTime} - ${item.endTimeStr}` : `${item.startTime} (active)`;
html += `<div class="week-event-block time_entry" data-id="${item.entry.id}" data-type="time_entry" style="${style}" title="${item.title}"><i class="fas fa-clock mr-1"></i><strong>${item.title}</strong><br><small>${durationText}</small></div>`;
html += `<div class="week-event-block time_entry" data-id="${item.entry.id}" data-type="time_entry" style="border-left-color: ${item.color}; ${style}" title="${item.title}" onclick="window.calendar.showEventDetails(${item.entry.id}, 'time_entry', event)"><i class="fas fa-clock mr-1"></i><strong>${item.title}</strong><br><small>${durationText}</small></div>`;
} else {
html += `<div class="week-event-block task" data-id="${item.task.id}" data-type="task" style="border-left-color: #f59e0b; ${style}" onclick="window.open('/tasks/${item.task.id}', '_blank'); event.stopPropagation();" title="${item.title}">\uD83D\uDCCB ${item.title}</div>`;
html += `<div class="week-event-block task" data-id="${item.task.id}" data-type="task" style="border-left-color: ${item.color}; ${style}" onclick="window.calendar.showEventDetails(${item.task.id}, 'task', event); event.stopPropagation();" title="${item.title}">\uD83D\uDCCB ${item.title}</div>`;
}
});
return html;
@@ -629,7 +692,7 @@ class Calendar {
if (count < maxDisplay) {
const eventTitle = this.escapeHtml(event.title);
const eventColor = event.color || '#3b82f6';
html += `<div class="event-badge" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event'); event.stopPropagation();" title="${eventTitle}">📅 ${eventTitle}</div>`;
html += `<div class="event-badge" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event', event); event.stopPropagation();" title="${eventTitle}">📅 ${eventTitle}</div>`;
}
count++;
}
@@ -643,7 +706,8 @@ class Calendar {
if (taskDate.toDateString() === day.toDateString()) {
if (count < maxDisplay) {
const taskTitle = this.escapeHtml(task.title);
html += `<div class="event-badge task-badge" onclick="window.open('/tasks/${task.id}', '_blank'); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
const taskColor = task.color || '#f59e0b';
html += `<div class="event-badge task-badge" style="background-color: ${taskColor}" onclick="window.calendar.showEventDetails(${task.id}, 'task', event); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
}
count++;
}
@@ -657,7 +721,8 @@ class Calendar {
if (entryStart >= dayStart && entryStart <= dayEnd) {
if (count < maxDisplay) {
const entryTitle = this.escapeHtml(entry.title);
html += `<div class="event-badge time-entry-badge" onclick="event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
const entryColor = entry.color || '#10b981';
html += `<div class="event-badge time-entry-badge" style="background-color: ${entryColor}" onclick="window.calendar.showEventDetails(${entry.id}, 'time_entry', event); event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
}
count++;
}
@@ -679,19 +744,129 @@ class Calendar {
date.getFullYear() === today.getFullYear();
}
async showEventDetails(id, type) {
// Navigate to the appropriate detail page
showEventDetails(id, type, clickEvent) {
const modal = document.getElementById('eventModal');
const modalTitle = document.querySelector('#eventModal .modal-title');
const bodyEl = document.getElementById('eventDetails');
const goToBtn = document.getElementById('eventModalGoToBtn');
const editEventBtn = document.getElementById('editEventBtn');
const deleteEventBtn = document.getElementById('deleteEventBtn');
if (!modal || !bodyEl) return;
const idStr = String(id);
let item = null;
if (type === 'event') {
window.location.href = `/calendar/event/${id}`;
item = this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'event');
} else if (type === 'task') {
window.location.href = `/tasks/${id}`;
item = this.tasks.find(t => String(t.id) === idStr) ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'task');
} else if (type === 'time_entry') {
// Time entries are displayed for context only - they're not clickable
// Users can manage time entries via the Timer/Reports sections
console.log('Time entry clicked:', id);
item = this.timeEntries.find(e => String(e.id) === idStr) ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && e.extendedProps.item_type) === 'time_entry') ||
this.events.find(e => String(e.id) === idStr && (e.extendedProps && (e.extendedProps.type === 'time_entry' || e.extendedProps.duration_hours != null || e.extendedProps.source != null))) ||
this.events.find(e => String(e.id) === idStr);
}
if (!item) {
bodyEl.innerHTML = '<p class="text-muted">Details not available.</p>';
if (modalTitle) modalTitle.textContent = type === 'event' ? 'Event' : type === 'task' ? 'Task' : 'Time Entry';
let detailUrl = type === 'event' ? `/calendar/event/${id}` : type === 'task' ? `/tasks/${id}` : `/timer/edit/${id}`;
if (goToBtn) { goToBtn.href = detailUrl; goToBtn.style.display = ''; }
if (editEventBtn) { editEventBtn.href = detailUrl; editEventBtn.style.display = ''; }
if (deleteEventBtn) deleteEventBtn.style.display = 'none';
modal.style.display = 'block';
modal.classList.add('show');
this._positionModalNearClick(modal, clickEvent);
return;
}
const props = item.extendedProps || {};
const formatDate = (d) => {
if (!d) return '—';
const dt = new Date(d);
return dt.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
};
// Use effective type for link: registered time (time entries) must go to /timer/edit/, not /calendar/event/
let effectiveType = props.item_type || props.type || type;
const looksLikeTimeEntry = props.duration_hours != null || props.source != null || (props.projectId != null && item.start && item.end && !item.allDay);
if (effectiveType === 'event' && (props.item_type === 'time_entry' || props.type === 'time_entry' || looksLikeTimeEntry)) {
effectiveType = 'time_entry';
}
if (effectiveType !== 'task' && effectiveType !== 'event' && looksLikeTimeEntry) {
effectiveType = 'time_entry';
}
let detailUrl = '#';
let titleLabel = 'Event';
if (effectiveType === 'event') {
detailUrl = `/calendar/event/${item.id}`;
titleLabel = 'Event Details';
} else if (effectiveType === 'task') {
detailUrl = `/tasks/${item.id}`;
titleLabel = 'Task';
} else {
detailUrl = `/timer/edit/${item.id}`;
titleLabel = 'Time Entry Details';
}
if (modalTitle) modalTitle.textContent = titleLabel;
if (goToBtn) { goToBtn.href = detailUrl; goToBtn.style.display = ''; }
if (editEventBtn) { editEventBtn.href = detailUrl; editEventBtn.innerHTML = '<i class="fas fa-external-link-alt mr-2"></i>Go to all details'; editEventBtn.style.display = ''; }
if (deleteEventBtn) deleteEventBtn.style.display = 'none';
if (type === 'task') {
const dueStr = item.start ? formatDate(item.start) : (props.dueDate || '—');
bodyEl.innerHTML = `
<div class="event-detail-row"><div class="event-detail-label">Title</div><div class="event-detail-value">${this.escapeHtml(item.title || props.title || '')}</div></div>
<div class="event-detail-row"><div class="event-detail-label">Due</div><div class="event-detail-value">${dueStr}</div></div>
${props.status ? `<div class="event-detail-row"><div class="event-detail-label">Status</div><div class="event-detail-value">${this.escapeHtml(props.status)}</div></div>` : ''}
${props.project_name ? `<div class="event-detail-row"><div class="event-detail-label">Project</div><div class="event-detail-value">${this.escapeHtml(props.project_name)}</div></div>` : ''}
`;
} else {
const startStr = item.start ? formatDate(item.start) : '—';
const endStr = item.end ? formatDate(item.end) : '—';
const duration = (props.duration_hours != null) ? Number(props.duration_hours).toFixed(2) : '—';
bodyEl.innerHTML = `
<div class="event-detail-row"><div class="event-detail-label">Project</div><div class="event-detail-value">${this.escapeHtml(props.project_name || '')}</div></div>
${props.task_name ? `<div class="event-detail-row"><div class="event-detail-label">Task</div><div class="event-detail-value">${this.escapeHtml(props.task_name)}</div></div>` : ''}
<div class="event-detail-row"><div class="event-detail-label">Start</div><div class="event-detail-value">${startStr}</div></div>
<div class="event-detail-row"><div class="event-detail-label">End</div><div class="event-detail-value">${endStr}</div></div>
<div class="event-detail-row"><div class="event-detail-label">Duration</div><div class="event-detail-value">${duration} hours</div></div>
${props.notes ? `<div class="event-detail-row"><div class="event-detail-label">Notes</div><div class="event-detail-value">${this.escapeHtml(props.notes)}</div></div>` : ''}
${props.tags ? `<div class="event-detail-row"><div class="event-detail-label">Tags</div><div class="event-detail-value">${this.escapeHtml(props.tags)}</div></div>` : ''}
`;
}
modal.style.display = 'block';
modal.classList.add('show');
this._positionModalNearClick(modal, clickEvent);
}
_positionModalNearClick(modal, clickEvent) {
const contentEl = modal && modal.querySelector('.modal-dialog');
if (!contentEl) return;
if (!clickEvent || !clickEvent.clientX) {
contentEl.style.position = '';
contentEl.style.left = '';
contentEl.style.top = '';
return;
}
const pad = 16;
const x = clickEvent.clientX;
const y = clickEvent.clientY;
contentEl.style.position = 'fixed';
requestAnimationFrame(() => {
const rect = contentEl.getBoundingClientRect();
let left = x + pad;
let top = y + pad;
if (left + rect.width > window.innerWidth - pad) left = window.innerWidth - rect.width - pad;
if (top + rect.height > window.innerHeight - pad) top = window.innerHeight - rect.height - pad;
if (left < pad) left = pad;
if (top < pad) top = pad;
contentEl.style.left = left + 'px';
contentEl.style.top = top + 'px';
});
}
escapeHtml(text) {
const map = {
'&': '&amp;',

View File

@@ -84,6 +84,34 @@
<span class="ml-2">{{ _('Time Entries') }}</span>
</label>
</div>
<!-- Calendar colors -->
<div class="flex flex-wrap items-center gap-4 mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ _('Calendar colors') }}</span>
<div class="flex flex-wrap items-center gap-4">
<label class="inline-flex items-center gap-2">
<span class="text-sm">{{ _('Events') }}</span>
<input type="color" id="calendarColorEvents" value="{{ type_colors.event }}" class="h-8 w-12 cursor-pointer rounded border border-border-light dark:border-border-dark" title="{{ _('Events') }}">
</label>
<label class="inline-flex items-center gap-2">
<span class="text-sm">{{ _('Tasks') }}</span>
<input type="color" id="calendarColorTasks" value="{{ type_colors.task }}" class="h-8 w-12 cursor-pointer rounded border border-border-light dark:border-border-dark" title="{{ _('Tasks') }}">
</label>
<label class="inline-flex items-center gap-2">
<span class="text-sm">{{ _('Time Entries') }}</span>
<input type="color" id="calendarColorTimeEntries" value="{{ type_colors.time_entry }}" class="h-8 w-12 cursor-pointer rounded border border-border-light dark:border-border-dark" title="{{ _('Time Entries') }}">
</label>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted">{{ _('Legend') }}:</span>
<span class="inline-flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ type_colors.event }}"></span> {{ _('Events') }}</span>
<span class="inline-flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ type_colors.task }}"></span> {{ _('Tasks') }}</span>
<span class="inline-flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ type_colors.time_entry }}"></span> {{ _('Time Entries') }}</span>
</div>
<button type="button" id="saveCalendarColorsBtn" class="btn btn-sm btn-primary">
<i class="fas fa-save mr-1"></i>{{ _('Save') }}
</button>
</div>
</div>
<!-- Calendar Grid -->
@@ -103,9 +131,14 @@
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ _('Event Details') }}</h3>
<button type="button" class="close" data-dismiss="modal">
<i class="fas fa-times"></i>
</button>
<div class="modal-header-actions">
<a href="#" id="eventModalGoToBtn" class="btn btn-primary btn-sm" style="display: none;">
<i class="fas fa-external-link-alt me-1"></i>{{ _('Go to all details') }}
</a>
<button type="button" class="close" data-dismiss="modal">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="modal-body" id="eventDetails">
<!-- Event details will be loaded here -->
@@ -137,7 +170,13 @@
deleteEventUrl: '{{ url_for('calendar.delete_event', event_id=0) }}'.replace('/0', ''),
moveEventUrl: '{{ url_for('calendar.move_event', event_id=0) }}'.replace('/0', ''),
resizeEventUrl: '{{ url_for('calendar.resize_event', event_id=0) }}'.replace('/0', ''),
csrfToken: '{{ csrf_token() }}'
csrfToken: '{{ csrf_token() }}',
typeColors: {
event: '{{ type_colors.event }}',
task: '{{ type_colors.task }}',
time_entry: '{{ type_colors.time_entry }}'
},
preferencesUrl: '{{ url_for('user.update_preferences') }}'
};
</script>
{% endblock %}

View File

@@ -7,7 +7,7 @@
PAGE HEADERS
============================================ #}
{% macro page_header(icon_class, title_text, subtitle_text=None, actions_html=None, breadcrumbs=None) %}
<div class="bg-gradient-to-r from-card-light to-card-light/50 dark:from-card-dark dark:to-card-dark/50 rounded-lg shadow-sm mb-6 overflow-hidden">
<div class="bg-gradient-to-r from-card-light to-card-light/50 dark:from-card-dark dark:to-card-dark/50 rounded-lg shadow-sm mb-6 overflow-visible relative z-20">
{% if breadcrumbs %}
<div class="px-6 pt-4 pb-2">
{{ breadcrumb_nav(breadcrumbs) }}

View File

@@ -10,8 +10,8 @@
] %}
{% set kanban_actions %}
<div class="flex items-center gap-3 flex-wrap">
<a href="{{ url_for('tasks.create_task', project_id=project_id if project_id else none, next=request.full_path) }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<div class="flex items-end gap-3 flex-wrap">
<a href="{{ url_for('tasks.create_task', project_id=project_id if project_id else none, next=request.full_path) }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded-lg hover:bg-primary/90 transition-colors shrink-0">
<i class="fas fa-plus"></i> {{ _('Add task') }}
</a>
<form method="get" id="kanbanFilterForm" class="flex items-center gap-3 flex-wrap">
@@ -43,7 +43,7 @@
</div>
</form>
{% if current_user.is_admin %}
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:bg-primary/90 transition-colors">
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded-lg hover:bg-primary/90 transition-colors shrink-0">
<i class="fas fa-sliders-h"></i> {{ _('Manage Columns') }}
</a>
{% endif %}

View File

@@ -71,7 +71,8 @@
item_id_attr='id',
item_label_attr='name',
placeholder='All Projects',
show_search=True
show_search=True,
form_id='tasksFilterForm'
) }}
</div>
<div>
@@ -83,7 +84,8 @@
item_id_attr='id',
item_label_attr='display_name',
placeholder='All Users',
show_search=True
show_search=True,
form_id='tasksFilterForm'
) }}
</div>
<div class="flex items-center pt-5">

View File

@@ -5,6 +5,11 @@
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='calendar.css') }}">
<style>
.event-modal-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.event-modal-header h3 { margin: 0; flex: 1; }
.event-modal-header-actions { display: flex; align-items: center; gap: 0.5rem; }
</style>
{% endblock %}
{% block content %}
@@ -209,8 +214,13 @@
<div class="event-modal" id="eventDetailModal">
<div class="event-modal-content">
<div class="event-modal-header">
<h3><i class="fas fa-clock me-2 text-primary"></i>{{ _('Time Entry Details') }}</h3>
<button class="event-modal-close" onclick="closeModal('eventDetailModal')">&times;</button>
<h3 id="eventDetailModalTitle"><i class="fas fa-clock me-2 text-primary"></i>{{ _('Time Entry Details') }}</h3>
<div class="event-modal-header-actions">
<a href="#" id="eventDetailGoToBtn" class="btn btn-primary btn-sm event-detail-goto-btn" style="display: none;">
<i class="fas fa-external-link-alt me-1"></i>{{ _('Go to all details') }}
</a>
<button class="event-modal-close" onclick="closeModal('eventDetailModal')">&times;</button>
</div>
</div>
<div class="event-modal-body">
<div class="event-detail" id="eventDetailContent">
@@ -443,7 +453,7 @@ document.addEventListener('DOMContentLoaded', function() {
},
eventClick: function(info) {
showEventDetails(info.event);
showEventDetails(info.event, info.jsEvent);
},
eventDrop: async function(info) {
@@ -765,13 +775,64 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
function showEventDetails(event) {
const props = event.extendedProps;
function showEventDetails(event, clickEvent) {
const props = event.extendedProps || {};
// Prefer item_type; backend also sends "type" (time_entry/event/task). If missing, infer from props (duration_hours/source = time entry).
let itemType = props.item_type || props.type || null;
if (!itemType && (props.duration_hours != null || props.source != null || (props.projectId != null && !event.allDay))) {
itemType = 'time_entry';
}
if (!itemType) itemType = 'time_entry';
// Force time_entry link when event has time-entry-only props (registered time must go to /timer/edit/)
if (itemType === 'event' && (props.duration_hours != null || props.source != null || (props.projectId != null && !event.allDay))) {
itemType = 'time_entry';
}
const content = document.getElementById('eventDetailContent');
// Store current event for duplication
currentEventForDuplication = event;
const goToBtn = document.getElementById('eventDetailGoToBtn');
const modalTitle = document.getElementById('eventDetailModalTitle');
const editEventBtn = document.getElementById('editEventBtn');
const deleteEventBtn = document.getElementById('deleteEventBtn');
const duplicateEventBtn = document.getElementById('duplicateEventBtn');
// Set detail URL and "Go to all details" by type
let detailUrl = '#';
let titleLabel = '{{ _("Time Entry Details") }}';
let titleIcon = 'fa-clock';
if (itemType === 'time_entry') {
detailUrl = `/timer/edit/${event.id}`;
titleLabel = '{{ _("Time Entry Details") }}';
titleIcon = 'fa-clock';
} else if (itemType === 'event') {
detailUrl = `/calendar/event/${event.id}`;
titleLabel = '{{ _("Event Details") }}';
titleIcon = 'fa-calendar';
} else if (itemType === 'task') {
detailUrl = `/tasks/${event.id}`;
titleLabel = '{{ _("Task") }}';
titleIcon = 'fa-tasks';
}
goToBtn.href = detailUrl;
goToBtn.style.display = '';
modalTitle.innerHTML = `<i class="fas ${titleIcon} me-2 text-primary"></i>${titleLabel}`;
editEventBtn.href = detailUrl;
editEventBtn.innerHTML = itemType === 'time_entry' ? '<i class="fas fa-edit me-1"></i>{{ _("Edit") }}' : '<i class="fas fa-external-link-alt me-1"></i>{{ _("Go to all details") }}';
// Show/hide time-entry-only actions
const isTimeEntry = itemType === 'time_entry';
deleteEventBtn.style.display = isTimeEntry ? '' : 'none';
duplicateEventBtn.style.display = isTimeEntry ? '' : 'none';
if (isTimeEntry) {
currentEventForDuplication = event;
deleteEventBtn.onclick = async () => {
const confirmed = await showConfirm(
'{{ _("Are you sure you want to delete this entry?") }}',
{ title: '{{ _("Delete Entry") }}', confirmText: '{{ _("Delete") }}', cancelText: '{{ _("Cancel") }}', variant: 'danger' }
);
if (confirmed) await deleteEvent(event.id);
};
duplicateEventBtn.onclick = async () => { await duplicateEvent(event); };
}
const formatDate = (date) => {
return new Date(date).toLocaleString('{{ current_user.preferred_language or "en" }}', {
year: 'numeric',
@@ -781,87 +842,81 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit'
});
};
content.innerHTML = `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Project") }}</div>
<div class="event-detail-value">${props.project_name || '{{ _("N/A") }}'}</div>
</div>
${props.task_name ? `
if (itemType === 'task') {
const dueStr = event.start ? formatDate(event.start) : (props.dueDate || '{{ _("N/A") }}');
content.innerHTML = `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Task") }}</div>
<div class="event-detail-value">${props.task_name}</div>
<div class="event-detail-label">{{ _("Title") }}</div>
<div class="event-detail-value">${event.title || props.title || '{{ _("N/A") }}'}</div>
</div>
` : ''}
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Start") }}</div>
<div class="event-detail-value">${formatDate(event.start)}</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("End") }}</div>
<div class="event-detail-value">${formatDate(event.end)}</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Duration") }}</div>
<div class="event-detail-value">${(props.duration_hours || 0).toFixed(2)} {{ _("hours") }}</div>
</div>
${props.notes ? `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Notes") }}</div>
<div class="event-detail-value">${props.notes}</div>
<div class="event-detail-label">{{ _("Due") }}</div>
<div class="event-detail-value">${dueStr}</div>
</div>
` : ''}
${props.tags ? `
${props.status ? `<div class="event-detail-row"><div class="event-detail-label">{{ _("Status") }}</div><div class="event-detail-value">${props.status}</div></div>` : ''}
${props.project_name ? `<div class="event-detail-row"><div class="event-detail-label">{{ _("Project") }}</div><div class="event-detail-value">${props.project_name}</div></div>` : ''}
`;
} else {
content.innerHTML = `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Tags") }}</div>
<div class="event-detail-value">${props.tags}</div>
<div class="event-detail-label">{{ _("Project") }}</div>
<div class="event-detail-value">${props.project_name || '{{ _("N/A") }}'}</div>
</div>
` : ''}
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Billable") }}</div>
<div class="event-detail-value">
<span class="event-detail-badge ${props.billable ? 'billable' : 'non-billable'}">
${props.billable ? '{{ _("Yes") }}' : '{{ _("No") }}'}
</span>
${props.task_name ? `<div class="event-detail-row"><div class="event-detail-label">{{ _("Task") }}</div><div class="event-detail-value">${props.task_name}</div></div>` : ''}
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Start") }}</div>
<div class="event-detail-value">${formatDate(event.start)}</div>
</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Source") }}</div>
<div class="event-detail-value">${props.source === 'auto' ? '{{ _("Automatic Timer") }}' : '{{ _("Manual Entry") }}'}</div>
</div>
`;
// Set up edit and delete buttons
document.getElementById('editEventBtn').href = `/timer/edit/${event.id}`;
document.getElementById('deleteEventBtn').onclick = async () => {
const confirmed = await showConfirm(
'{{ _("Are you sure you want to delete this entry?") }}',
{
title: '{{ _("Delete Entry") }}',
confirmText: '{{ _("Delete") }}',
cancelText: '{{ _("Cancel") }}',
variant: 'danger'
}
);
if (confirmed) {
await deleteEvent(event.id);
}
};
// Set up duplicate button
document.getElementById('duplicateEventBtn').onclick = async () => {
await duplicateEvent(event);
};
<div class="event-detail-row">
<div class="event-detail-label">{{ _("End") }}</div>
<div class="event-detail-value">${formatDate(event.end)}</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Duration") }}</div>
<div class="event-detail-value">${(props.duration_hours != null ? props.duration_hours : 0).toFixed(2)} {{ _("hours") }}</div>
</div>
${props.notes ? `<div class="event-detail-row"><div class="event-detail-label">{{ _("Notes") }}</div><div class="event-detail-value">${props.notes}</div></div>` : ''}
${props.tags ? `<div class="event-detail-row"><div class="event-detail-label">{{ _("Tags") }}</div><div class="event-detail-value">${props.tags}</div></div>` : ''}
${itemType === 'time_entry' ? `
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Billable") }}</div>
<div class="event-detail-value">
<span class="event-detail-badge ${props.billable ? 'billable' : 'non-billable'}">${props.billable ? '{{ _("Yes") }}' : '{{ _("No") }}'}</span>
</div>
</div>
<div class="event-detail-row">
<div class="event-detail-label">{{ _("Source") }}</div>
<div class="event-detail-value">${props.source === 'auto' ? '{{ _("Automatic Timer") }}' : '{{ _("Manual Entry") }}'}</div>
</div>
` : ''}
`;
}
openModal('eventDetailModal');
const modalEl = document.getElementById('eventDetailModal');
const contentEl = modalEl && modalEl.querySelector('.event-modal-content');
if (clickEvent && contentEl) {
const pad = 16;
const x = clickEvent.clientX;
const y = clickEvent.clientY;
contentEl.style.position = 'fixed';
requestAnimationFrame(function() {
const rect = contentEl.getBoundingClientRect();
let left = x + pad;
let top = y + pad;
if (left + rect.width > window.innerWidth - pad) left = window.innerWidth - rect.width - pad;
if (top + rect.height > window.innerHeight - pad) top = window.innerHeight - rect.height - pad;
if (left < pad) left = pad;
if (top < pad) top = pad;
contentEl.style.left = left + 'px';
contentEl.style.top = top + 'px';
});
} else if (contentEl) {
contentEl.style.position = '';
contentEl.style.left = '';
contentEl.style.top = '';
}
}
async function deleteEvent(eventId) {