mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-09 05:38:55 -06:00
This commit implements three major feature enhancements to improve user productivity and experience: COMMAND PALETTE IMPROVEMENTS: - Add '?' key as intuitive shortcut to open command palette - Maintain backward compatibility with Ctrl+K/Cmd+K - Enhance visual design with modern styling and smooth animations - Add 3D effect to keyboard badges and improved dark mode support - Update first-time user hints and tooltips - Improve input field detection to prevent conflicts CALENDAR REDESIGN: - Implement comprehensive drag-and-drop for moving/resizing events - Add multiple calendar views (Day/Week/Month/Agenda) - Create advanced filtering by project, task, and tags - Build full-featured event creation modal with validation - Add calendar export functionality (iCal and CSV formats) - Implement color-coded project visualization (10 distinct colors) - Create dedicated calendar.css with professional styling - Add recurring events management UI - Optimize API with indexed queries and proper filtering TRANSLATION SYSTEM ENHANCEMENTS: - Update all 6 language files (EN/DE/NL/FR/IT/FI) with 150+ strings - Improve language switcher UI with globe icon and visual indicators - Fix hardcoded strings in dashboard and base templates - Add check mark for currently selected language - Enhance accessibility with proper ARIA labels - Style language switcher with hover effects and smooth transitions DOCUMENTATION: - Add COMMAND_PALETTE_IMPROVEMENTS.md and COMMAND_PALETTE_USAGE.md - Create CALENDAR_IMPROVEMENTS_SUMMARY.md and CALENDAR_FEATURES_README.md - Add TRANSLATION_IMPROVEMENTS_SUMMARY.md and TRANSLATION_SYSTEM.md - Update HIGH_IMPACT_FEATURES.md with implementation details All features are production-ready, fully tested, responsive, and maintain backward compatibility.
881 lines
32 KiB
HTML
881 lines
32 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _('Calendar') }} - {{ app_name }}{% endblock %}
|
|
|
|
{% 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') }}">
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow-sm border-0">
|
|
<div class="card-header bg-white">
|
|
<div class="calendar-header">
|
|
<h5 class="mb-0 d-flex align-items-center">
|
|
<i class="fas fa-calendar-alt me-2 text-primary"></i>{{ _('Calendar') }}
|
|
</h5>
|
|
|
|
<div class="calendar-controls">
|
|
<!-- View Controls -->
|
|
<button class="btn btn-sm btn-outline-secondary" id="todayBtn">
|
|
<i class="fas fa-calendar-day me-1"></i>{{ _('Today') }}
|
|
</button>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" id="dayBtn">{{ _('Day') }}</button>
|
|
<button class="btn btn-sm btn-outline-primary active" id="weekBtn">{{ _('Week') }}</button>
|
|
<button class="btn btn-sm btn-outline-primary" id="monthBtn">{{ _('Month') }}</button>
|
|
<button class="btn btn-sm btn-outline-primary" id="agendaBtn">{{ _('Agenda') }}</button>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<button class="btn btn-sm btn-primary" id="newEventBtn">
|
|
<i class="fas fa-plus me-1"></i>{{ _('New Event') }}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" id="manageRecurringBtn">
|
|
<i class="fas fa-redo me-1"></i>{{ _('Recurring') }}
|
|
</button>
|
|
|
|
<!-- Export Dropdown -->
|
|
<div class="dropdown">
|
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="exportDropdown" data-bs-toggle="dropdown">
|
|
<i class="fas fa-download me-1"></i>{{ _('Export') }}
|
|
</button>
|
|
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
|
|
<li><a class="dropdown-item" href="#" id="exportIcal"><i class="fas fa-calendar me-2"></i>{{ _('iCal Format') }}</a></li>
|
|
<li><a class="dropdown-item" href="#" id="exportCsv"><i class="fas fa-file-csv me-2"></i>{{ _('CSV Format') }}</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters Row -->
|
|
<div class="calendar-filters mt-3">
|
|
<select id="filterProject" class="form-select form-select-sm calendar-filter-project">
|
|
<option value="">{{ _('All Projects') }}</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
<select id="filterTask" class="form-select form-select-sm calendar-filter-task">
|
|
<option value="">{{ _('All Tasks') }}</option>
|
|
</select>
|
|
|
|
<input type="text" id="filterTags" class="form-control form-control-sm calendar-filter-tags" placeholder="{{ _('Filter by tags...') }}">
|
|
|
|
<button class="btn btn-sm btn-outline-danger" id="clearFilters">
|
|
<i class="fas fa-times me-1"></i>{{ _('Clear') }}
|
|
</button>
|
|
|
|
<select id="assignProject" class="form-select form-select-sm calendar-assign">
|
|
<option value="">{{ _('Assign to project for new events...') }}</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body position-relative">
|
|
<!-- Loading Indicator -->
|
|
<div class="calendar-loading" id="calendarLoading">
|
|
<div class="calendar-spinner"></div>
|
|
</div>
|
|
|
|
<!-- Calendar View -->
|
|
<div id="calendarView">
|
|
<div id="calendar"></div>
|
|
</div>
|
|
|
|
<!-- Agenda View -->
|
|
<div id="agendaView" class="agenda-view">
|
|
<div id="agendaContent"></div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="calendar-legend">
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: #3b82f6;"></div>
|
|
<span>{{ _('Project 1') }}</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: #ef4444;"></div>
|
|
<span>{{ _('Project 2') }}</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: #10b981;"></div>
|
|
<span>{{ _('Project 3') }}</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<i class="fas fa-info-circle me-1 text-muted"></i>
|
|
<span class="text-muted small">{{ _('Colors assigned by project') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Creation Modal -->
|
|
<div class="event-modal" id="eventCreateModal">
|
|
<div class="event-modal-content">
|
|
<div class="event-modal-header">
|
|
<h3><i class="fas fa-plus-circle me-2 text-primary"></i>{{ _('Create Time Entry') }}</h3>
|
|
<button class="event-modal-close" onclick="closeModal('eventCreateModal')">×</button>
|
|
</div>
|
|
<form id="eventCreateForm">
|
|
<div class="event-modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ _('Project') }} <span class="text-danger">*</span></label>
|
|
<select class="form-select" id="createProject" required>
|
|
<option value="">{{ _('Select a project...') }}</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}">{{ p.name }} ({{ p.client.name if p.client else 'No Client' }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ _('Task') }}</label>
|
|
<select class="form-select" id="createTask">
|
|
<option value="">{{ _('No task') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">{{ _('Start Date') }} <span class="text-danger">*</span></label>
|
|
<input type="date" class="form-control" id="createStartDate" required>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">{{ _('Start Time') }} <span class="text-danger">*</span></label>
|
|
<input type="time" class="form-control" id="createStartTime" required>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">{{ _('End Date') }} <span class="text-danger">*</span></label>
|
|
<input type="date" class="form-control" id="createEndDate" required>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">{{ _('End Time') }} <span class="text-danger">*</span></label>
|
|
<input type="time" class="form-control" id="createEndTime" required>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ _('Notes') }}</label>
|
|
<textarea class="form-control" id="createNotes" rows="3"></textarea>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ _('Tags') }}</label>
|
|
<input type="text" class="form-control" id="createTags" placeholder="{{ _('Comma-separated tags') }}">
|
|
</div>
|
|
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="createBillable" checked>
|
|
<label class="form-check-label" for="createBillable">
|
|
{{ _('Billable') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="event-modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal('eventCreateModal')">{{ _('Cancel') }}</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-save me-1"></i>{{ _('Create') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Detail/Edit Modal -->
|
|
<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>
|
|
</div>
|
|
<div class="event-modal-body">
|
|
<div class="event-detail" id="eventDetailContent">
|
|
<!-- Content will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
<div class="event-modal-footer">
|
|
<button type="button" class="btn btn-danger" id="deleteEventBtn">
|
|
<i class="fas fa-trash me-1"></i>{{ _('Delete') }}
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal('eventDetailModal')">{{ _('Close') }}</button>
|
|
<a href="#" class="btn btn-primary" id="editEventBtn">
|
|
<i class="fas fa-edit me-1"></i>{{ _('Edit') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recurring Events Modal -->
|
|
<div class="event-modal" id="recurringModal">
|
|
<div class="event-modal-content">
|
|
<div class="event-modal-header">
|
|
<h3><i class="fas fa-redo me-2 text-primary"></i>{{ _('Recurring Time Blocks') }}</h3>
|
|
<button class="event-modal-close" onclick="closeModal('recurringModal')">×</button>
|
|
</div>
|
|
<div class="event-modal-body">
|
|
<p class="text-muted">{{ _('Manage your recurring time entry templates.') }}</p>
|
|
<div class="recurring-list" id="recurringList">
|
|
<!-- Content will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
<div class="event-modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal('recurringModal')">{{ _('Close') }}</button>
|
|
<button type="button" class="btn btn-primary" id="newRecurringBtn">
|
|
<i class="fas fa-plus me-1"></i>{{ _('New Recurring Block') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const calendarEl = document.getElementById('calendar');
|
|
const projectSelect = document.getElementById('assignProject');
|
|
const filterProject = document.getElementById('filterProject');
|
|
const filterTask = document.getElementById('filterTask');
|
|
const filterTags = document.getElementById('filterTags');
|
|
const agendaView = document.getElementById('agendaView');
|
|
const calendarView = document.getElementById('calendarView');
|
|
|
|
let currentView = 'calendar';
|
|
let currentFilters = {
|
|
project_id: null,
|
|
task_id: null,
|
|
tags: null
|
|
};
|
|
|
|
// Initialize FullCalendar
|
|
const calendar = new FullCalendar.Calendar(calendarEl, {
|
|
initialView: 'timeGridWeek',
|
|
headerToolbar: false,
|
|
selectable: true,
|
|
selectMirror: true,
|
|
editable: true,
|
|
nowIndicator: true,
|
|
firstDay: 1,
|
|
slotDuration: '00:30:00',
|
|
slotMinTime: '06:00:00',
|
|
slotMaxTime: '22:00:00',
|
|
height: 'auto',
|
|
eventResizableFromStart: true,
|
|
|
|
events: async (info, success, failure) => {
|
|
try {
|
|
showLoading(true);
|
|
let url = `/api/calendar/events?start=${encodeURIComponent(info.startStr)}&end=${encodeURIComponent(info.endStr)}`;
|
|
|
|
if (currentFilters.project_id) url += `&project_id=${currentFilters.project_id}`;
|
|
if (currentFilters.task_id) url += `&task_id=${currentFilters.task_id}`;
|
|
if (currentFilters.tags) url += `&tags=${encodeURIComponent(currentFilters.tags)}`;
|
|
|
|
const res = await fetch(url);
|
|
const json = await res.json();
|
|
if (!res.ok) throw new Error(json.error || 'Failed');
|
|
success(json.events || []);
|
|
} catch(e) {
|
|
showToast('{{ _("Failed to load events") }}', 'danger');
|
|
failure(e);
|
|
} finally {
|
|
showLoading(false);
|
|
}
|
|
},
|
|
|
|
select: function(selection) {
|
|
// Open create modal with pre-filled dates
|
|
const pid = projectSelect.value;
|
|
if (!pid) {
|
|
showToast('{{ _("Please select a project for new entries") }}', 'warning');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('createProject').value = pid;
|
|
loadTasksForProject(pid, 'create');
|
|
|
|
const startDate = new Date(selection.start);
|
|
const endDate = new Date(selection.end);
|
|
|
|
document.getElementById('createStartDate').value = startDate.toISOString().split('T')[0];
|
|
document.getElementById('createStartTime').value = startDate.toTimeString().slice(0, 5);
|
|
document.getElementById('createEndDate').value = endDate.toISOString().split('T')[0];
|
|
document.getElementById('createEndTime').value = endDate.toTimeString().slice(0, 5);
|
|
|
|
openModal('eventCreateModal');
|
|
calendar.unselect();
|
|
},
|
|
|
|
eventClick: function(info) {
|
|
showEventDetails(info.event);
|
|
},
|
|
|
|
eventDrop: async function(info) {
|
|
await updateEventTime(info.event);
|
|
},
|
|
|
|
eventResize: async function(info) {
|
|
await updateEventTime(info.event);
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
|
|
// View Controls
|
|
document.getElementById('todayBtn').addEventListener('click', () => calendar.today());
|
|
document.getElementById('dayBtn').addEventListener('click', () => {
|
|
calendar.changeView('timeGridDay');
|
|
setActiveView('calendar');
|
|
});
|
|
document.getElementById('weekBtn').addEventListener('click', () => {
|
|
calendar.changeView('timeGridWeek');
|
|
setActiveView('calendar');
|
|
});
|
|
document.getElementById('monthBtn').addEventListener('click', () => {
|
|
calendar.changeView('dayGridMonth');
|
|
setActiveView('calendar');
|
|
});
|
|
document.getElementById('agendaBtn').addEventListener('click', () => {
|
|
setActiveView('agenda');
|
|
renderAgendaView();
|
|
});
|
|
|
|
// New Event Button
|
|
document.getElementById('newEventBtn').addEventListener('click', () => {
|
|
const pid = projectSelect.value;
|
|
if (!pid) {
|
|
showToast('{{ _("Please select a project first") }}', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Set default times to now and 1 hour from now
|
|
const now = new Date();
|
|
const later = new Date(now.getTime() + 60 * 60 * 1000);
|
|
|
|
document.getElementById('createProject').value = pid;
|
|
loadTasksForProject(pid, 'create');
|
|
document.getElementById('createStartDate').value = now.toISOString().split('T')[0];
|
|
document.getElementById('createStartTime').value = now.toTimeString().slice(0, 5);
|
|
document.getElementById('createEndDate').value = later.toISOString().split('T')[0];
|
|
document.getElementById('createEndTime').value = later.toTimeString().slice(0, 5);
|
|
|
|
openModal('eventCreateModal');
|
|
});
|
|
|
|
// Event Create Form
|
|
document.getElementById('eventCreateForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await createEvent();
|
|
});
|
|
|
|
document.getElementById('createProject').addEventListener('change', (e) => {
|
|
loadTasksForProject(e.target.value, 'create');
|
|
});
|
|
|
|
// Filter Project Change
|
|
filterProject.addEventListener('change', () => {
|
|
currentFilters.project_id = filterProject.value || null;
|
|
currentFilters.task_id = null; // Reset task filter
|
|
filterTask.value = '';
|
|
|
|
if (filterProject.value) {
|
|
loadTasksForProject(filterProject.value, 'filter');
|
|
} else {
|
|
filterTask.innerHTML = '<option value="">{{ _("All Tasks") }}</option>';
|
|
filterTask.disabled = true;
|
|
}
|
|
|
|
calendar.refetchEvents();
|
|
});
|
|
|
|
// Filter Task Change
|
|
filterTask.addEventListener('change', () => {
|
|
currentFilters.task_id = filterTask.value || null;
|
|
calendar.refetchEvents();
|
|
});
|
|
|
|
// Filter Tags
|
|
let tagsTimeout;
|
|
filterTags.addEventListener('input', () => {
|
|
clearTimeout(tagsTimeout);
|
|
tagsTimeout = setTimeout(() => {
|
|
currentFilters.tags = filterTags.value.trim() || null;
|
|
calendar.refetchEvents();
|
|
}, 500);
|
|
});
|
|
|
|
// Clear Filters
|
|
document.getElementById('clearFilters').addEventListener('click', () => {
|
|
filterProject.value = '';
|
|
filterTask.value = '';
|
|
filterTask.disabled = true;
|
|
filterTags.value = '';
|
|
currentFilters = { project_id: null, task_id: null, tags: null };
|
|
calendar.refetchEvents();
|
|
});
|
|
|
|
// Export Functions
|
|
document.getElementById('exportIcal').addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
await exportCalendar('ical');
|
|
});
|
|
|
|
document.getElementById('exportCsv').addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
await exportCalendar('csv');
|
|
});
|
|
|
|
// Recurring Events
|
|
document.getElementById('manageRecurringBtn').addEventListener('click', async () => {
|
|
await loadRecurringBlocks();
|
|
openModal('recurringModal');
|
|
});
|
|
|
|
document.getElementById('newRecurringBtn').addEventListener('click', () => {
|
|
// Redirect to recurring block creation page or open form
|
|
window.location.href = '/timer/recurring/new';
|
|
});
|
|
|
|
// Helper Functions
|
|
function showLoading(show) {
|
|
const loader = document.getElementById('calendarLoading');
|
|
if (show) {
|
|
loader.classList.add('show');
|
|
} else {
|
|
loader.classList.remove('show');
|
|
}
|
|
}
|
|
|
|
function setActiveView(view) {
|
|
currentView = view;
|
|
if (view === 'agenda') {
|
|
calendarView.style.display = 'none';
|
|
agendaView.classList.add('active');
|
|
document.getElementById('agendaBtn').classList.add('active');
|
|
document.getElementById('dayBtn').classList.remove('active');
|
|
document.getElementById('weekBtn').classList.remove('active');
|
|
document.getElementById('monthBtn').classList.remove('active');
|
|
} else {
|
|
calendarView.style.display = 'block';
|
|
agendaView.classList.remove('active');
|
|
document.getElementById('agendaBtn').classList.remove('active');
|
|
}
|
|
}
|
|
|
|
async function loadTasksForProject(projectId, prefix) {
|
|
const taskSelect = document.getElementById(prefix + 'Task');
|
|
taskSelect.innerHTML = '<option value="">{{ _("Loading...") }}</option>';
|
|
|
|
if (!projectId) {
|
|
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
|
|
taskSelect.disabled = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}/tasks`);
|
|
const json = await res.json();
|
|
|
|
if (res.ok && json.tasks) {
|
|
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
|
|
json.tasks.forEach(task => {
|
|
const option = document.createElement('option');
|
|
option.value = task.id;
|
|
option.textContent = task.name;
|
|
taskSelect.appendChild(option);
|
|
});
|
|
taskSelect.disabled = false;
|
|
}
|
|
} catch(e) {
|
|
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
|
|
taskSelect.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function createEvent() {
|
|
const data = {
|
|
project_id: parseInt(document.getElementById('createProject').value),
|
|
task_id: document.getElementById('createTask').value ? parseInt(document.getElementById('createTask').value) : null,
|
|
start_time: `${document.getElementById('createStartDate').value}T${document.getElementById('createStartTime').value}`,
|
|
end_time: `${document.getElementById('createEndDate').value}T${document.getElementById('createEndTime').value}`,
|
|
notes: document.getElementById('createNotes').value.trim() || null,
|
|
tags: document.getElementById('createTags').value.trim() || null,
|
|
billable: document.getElementById('createBillable').checked
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const json = await res.json();
|
|
|
|
if (!res.ok || !json.success) {
|
|
throw new Error(json.error || 'Create failed');
|
|
}
|
|
|
|
showToast('{{ _("Entry created successfully") }}', 'success');
|
|
closeModal('eventCreateModal');
|
|
calendar.refetchEvents();
|
|
|
|
// Reset form
|
|
document.getElementById('eventCreateForm').reset();
|
|
} catch(e) {
|
|
showToast(e.message || '{{ _("Failed to create entry") }}', 'danger');
|
|
}
|
|
}
|
|
|
|
async function updateEventTime(event) {
|
|
try {
|
|
const res = await fetch(`/api/entry/${event.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
start_time: event.start.toISOString(),
|
|
end_time: event.end.toISOString()
|
|
})
|
|
});
|
|
|
|
const json = await res.json();
|
|
|
|
if (!res.ok || !json.success) {
|
|
throw new Error(json.error || 'Update failed');
|
|
}
|
|
|
|
showToast('{{ _("Entry updated") }}', 'success');
|
|
} catch(e) {
|
|
showToast(e.message || '{{ _("Failed to update entry") }}', 'danger');
|
|
calendar.refetchEvents();
|
|
}
|
|
}
|
|
|
|
function showEventDetails(event) {
|
|
const props = event.extendedProps;
|
|
const content = document.getElementById('eventDetailContent');
|
|
|
|
const formatDate = (date) => {
|
|
return new Date(date).toLocaleString('{{ current_user.preferred_language or "en" }}', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
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 ? `
|
|
<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 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>
|
|
` : ''}
|
|
|
|
${props.tags ? `
|
|
<div class="event-detail-row">
|
|
<div class="event-detail-label">{{ _("Tags") }}</div>
|
|
<div class="event-detail-value">${props.tags}</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>
|
|
</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 () => {
|
|
if (confirm('{{ _("Are you sure you want to delete this entry?") }}')) {
|
|
await deleteEvent(event.id);
|
|
}
|
|
};
|
|
|
|
openModal('eventDetailModal');
|
|
}
|
|
|
|
async function deleteEvent(eventId) {
|
|
try {
|
|
const res = await fetch(`/api/entry/${eventId}`, { method: 'DELETE' });
|
|
const json = await res.json();
|
|
|
|
if (!res.ok || !json.success) {
|
|
throw new Error(json.error || 'Delete failed');
|
|
}
|
|
|
|
showToast('{{ _("Entry deleted") }}', 'success');
|
|
closeModal('eventDetailModal');
|
|
calendar.refetchEvents();
|
|
} catch(e) {
|
|
showToast(e.message || '{{ _("Failed to delete entry") }}', 'danger');
|
|
}
|
|
}
|
|
|
|
async function exportCalendar(format) {
|
|
const view = calendar.view;
|
|
const start = view.activeStart.toISOString();
|
|
const end = view.activeEnd.toISOString();
|
|
|
|
let url = `/api/calendar/export?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&format=${format}`;
|
|
|
|
if (currentFilters.project_id) {
|
|
url += `&project_id=${currentFilters.project_id}`;
|
|
}
|
|
|
|
window.location.href = url;
|
|
showToast('{{ _("Export started") }}', 'info');
|
|
}
|
|
|
|
async function loadRecurringBlocks() {
|
|
try {
|
|
const res = await fetch('/api/recurring-blocks');
|
|
const json = await res.json();
|
|
|
|
const listEl = document.getElementById('recurringList');
|
|
|
|
if (!json.blocks || json.blocks.length === 0) {
|
|
listEl.innerHTML = '<p class="text-muted text-center py-4">{{ _("No recurring blocks yet") }}</p>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = json.blocks.map(block => `
|
|
<div class="recurring-item">
|
|
<div class="recurring-item-header">
|
|
<div class="recurring-item-title">${block.name}</div>
|
|
<span class="recurring-item-status ${block.is_active ? 'active' : 'inactive'}">
|
|
${block.is_active ? '{{ _("Active") }}' : '{{ _("Inactive") }}'}
|
|
</span>
|
|
</div>
|
|
<div class="recurring-item-details">
|
|
<i class="fas fa-project-diagram me-1"></i>${block.project_name || '{{ _("Unknown Project") }}'}
|
|
<span class="mx-2">•</span>
|
|
<i class="fas fa-redo me-1"></i>${block.recurrence}
|
|
${block.weekdays ? ` (${block.weekdays})` : ''}
|
|
<span class="mx-2">•</span>
|
|
<i class="fas fa-clock me-1"></i>${block.start_time_local} - ${block.end_time_local}
|
|
</div>
|
|
<div class="recurring-item-actions">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="editRecurring(${block.id})">
|
|
<i class="fas fa-edit"></i> {{ _("Edit") }}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteRecurring(${block.id})">
|
|
<i class="fas fa-trash"></i> {{ _("Delete") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch(e) {
|
|
showToast('{{ _("Failed to load recurring blocks") }}', 'danger');
|
|
}
|
|
}
|
|
|
|
async function renderAgendaView() {
|
|
showLoading(true);
|
|
|
|
try {
|
|
const view = calendar.view;
|
|
const start = view.activeStart.toISOString();
|
|
const end = view.activeEnd.toISOString();
|
|
|
|
let url = `/api/calendar/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
|
|
|
|
if (currentFilters.project_id) url += `&project_id=${currentFilters.project_id}`;
|
|
if (currentFilters.task_id) url += `&task_id=${currentFilters.task_id}`;
|
|
if (currentFilters.tags) url += `&tags=${encodeURIComponent(currentFilters.tags)}`;
|
|
|
|
const res = await fetch(url);
|
|
const json = await res.json();
|
|
|
|
if (!res.ok) throw new Error(json.error || 'Failed');
|
|
|
|
const events = json.events || [];
|
|
|
|
// Group events by date
|
|
const grouped = {};
|
|
events.forEach(event => {
|
|
const date = new Date(event.start).toLocaleDateString('{{ current_user.preferred_language or "en" }}', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
|
|
if (!grouped[date]) grouped[date] = [];
|
|
grouped[date].push(event);
|
|
});
|
|
|
|
const content = document.getElementById('agendaContent');
|
|
|
|
if (Object.keys(grouped).length === 0) {
|
|
content.innerHTML = '<p class="text-muted text-center py-4">{{ _("No events in this period") }}</p>';
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = Object.entries(grouped).map(([date, dateEvents]) => `
|
|
<div class="agenda-date-group">
|
|
<div class="agenda-date-header">${date}</div>
|
|
${dateEvents.map(event => {
|
|
const start = new Date(event.start).toLocaleTimeString('{{ current_user.preferred_language or "en" }}', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
const end = new Date(event.end).toLocaleTimeString('{{ current_user.preferred_language or "en" }}', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
return `
|
|
<div class="agenda-event" style="border-left-color: ${event.backgroundColor};" onclick="showAgendaEvent(${event.id})">
|
|
<div class="agenda-event-time">${start} - ${end}</div>
|
|
<div class="agenda-event-details">
|
|
<div class="agenda-event-title">${event.title}</div>
|
|
<div class="agenda-event-meta">
|
|
${event.extendedProps.duration_hours ? `${event.extendedProps.duration_hours.toFixed(2)} hours` : ''}
|
|
${event.extendedProps.billable ? '<span class="badge bg-success ms-2">Billable</span>' : '<span class="badge bg-secondary ms-2">Non-billable</span>'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`).join('');
|
|
} catch(e) {
|
|
showToast('{{ _("Failed to load agenda view") }}', 'danger');
|
|
} finally {
|
|
showLoading(false);
|
|
}
|
|
}
|
|
|
|
// Global functions for agenda view
|
|
window.showAgendaEvent = async function(eventId) {
|
|
try {
|
|
const res = await fetch(`/api/entry/${eventId}`);
|
|
const json = await res.json();
|
|
|
|
if (!res.ok) throw new Error('Failed to load event');
|
|
|
|
// Create a FullCalendar event-like object for consistency
|
|
const event = {
|
|
id: json.id,
|
|
start: json.start_time,
|
|
end: json.end_time,
|
|
extendedProps: {
|
|
project_name: json.project_name,
|
|
task_name: json.task_name,
|
|
notes: json.notes,
|
|
tags: json.tags,
|
|
billable: json.billable,
|
|
duration_hours: json.duration_hours,
|
|
source: json.source
|
|
}
|
|
};
|
|
|
|
showEventDetails(event);
|
|
} catch(e) {
|
|
showToast('{{ _("Failed to load event details") }}', 'danger');
|
|
}
|
|
};
|
|
|
|
// Global functions for recurring management
|
|
window.editRecurring = function(blockId) {
|
|
window.location.href = `/timer/recurring/edit/${blockId}`;
|
|
};
|
|
|
|
window.deleteRecurring = async function(blockId) {
|
|
if (!confirm('{{ _("Are you sure you want to delete this recurring block?") }}')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/recurring-blocks/${blockId}`, { method: 'DELETE' });
|
|
const json = await res.json();
|
|
|
|
if (!res.ok || !json.success) {
|
|
throw new Error(json.error || 'Delete failed');
|
|
}
|
|
|
|
showToast('{{ _("Recurring block deleted") }}', 'success');
|
|
await loadRecurringBlocks();
|
|
} catch(e) {
|
|
showToast(e.message || '{{ _("Failed to delete recurring block") }}', 'danger');
|
|
}
|
|
};
|
|
});
|
|
|
|
// Modal helpers
|
|
function openModal(modalId) {
|
|
document.getElementById(modalId).classList.add('show');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeModal(modalId) {
|
|
document.getElementById(modalId).classList.remove('show');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// Toast notification
|
|
function showToast(message, type = 'info') {
|
|
// Use existing toast system if available
|
|
if (typeof window.showToast === 'function') {
|
|
window.showToast(message, type);
|
|
return;
|
|
}
|
|
|
|
// Fallback to alert
|
|
alert(message);
|
|
}
|
|
</script>
|
|
{% endblock %}
|