mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-31 08:59:48 -06:00
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:
@@ -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}%"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')">×</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')">×</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) {
|
||||
|
||||
Reference in New Issue
Block a user