Files
TimeTracker/templates/timer/calendar.html
T
Dries Peeters 20b7401891 feat: Add invoice expenses, enhanced PDF editor with Konva.js, and uploads persistence
Major Features:
- Invoice Expenses: Allow linking billable expenses to invoices with automatic total calculations
  - Add expenses to invoices via "Generate from Time/Costs" workflow
  - Display expenses in invoice view, edit forms, and PDF exports
  - Track expense states (approved, invoiced, reimbursed) with automatic unlinking on invoice deletion
  - Update PDF generator and CSV exports to include expense line items

- Enhanced PDF Invoice Editor: Complete redesign using Konva.js for visual drag-and-drop layout design
  - Add 40+ draggable elements (company info, invoice data, shapes, text, advanced elements)
  - Implement comprehensive properties panel for precise element customization (position, fonts, colors, opacity)
  - Add canvas toolbar with alignment tools, zoom controls, and layer management
  - Support keyboard shortcuts (copy/paste, duplicate, arrow key positioning)
  - Save designs as JSON for editing and generate clean HTML/CSS for rendering
  - Add real-time preview with live data

- Uploads Persistence: Implement Docker volume persistence for user-uploaded files
  - Add app_uploads volume to all Docker Compose configurations
  - Ensure company logos and avatars persist across container rebuilds and restarts
  - Create migration script for existing installations
  - Update directory structure with proper permissions (755 for dirs, 644 for files)

Database & Backend:
- Add invoice_pdf_design_json column to settings table via Alembic migration
- Extend Invoice model with expenses relationship
- Update admin routes for PDF layout designer endpoints
- Enhance invoice routes to handle expense linking/unlinking

Frontend & UI:
- Redesign PDF layout editor template with Konva.js canvas (2484 lines, major overhaul)
- Update invoice edit/view templates to display and manage expenses
- Add expense sections to invoice forms with unlink functionality
- Enhance UI components with keyboard shortcuts support
- Update multiple templates for consistency and accessibility

Testing & Documentation:
- Add comprehensive test suites for invoice expenses, PDF layouts, and uploads persistence
- Create detailed documentation for all new features (5 new docs)
- Include migration guides and troubleshooting sections

Infrastructure:
- Update docker-compose files (main, example, remote, remote-dev, local-test) with uploads volume
- Configure pytest for new test modules
- Add template filters for currency formatting and expense display

This update significantly enhances TimeTracker's invoice management capabilities,
improves the PDF customization experience, and ensures uploaded files persist
reliably across deployments.
2025-10-29 15:03:01 +01:00

1379 lines
48 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...') }}">
<!-- Quick Filter: Billable Only -->
<button class="btn btn-sm btn-outline-success" id="filterBillableOnly" title="{{ _('Show billable entries only') }}">
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable Only') }}
</button>
<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>
<!-- Total Hours Display -->
<div class="calendar-hours-summary">
<span class="text-muted small">{{ _('Total Hours:') }}</span>
<strong id="totalHoursDisplay" class="ms-1 text-primary">0h</strong>
</div>
</div>
</div>
<div class="card-body position-relative">
<!-- Loading Indicator -->
<div class="calendar-loading" id="calendarLoading">
<div class="calendar-spinner"></div>
</div>
<!-- Daily Capacity Bar -->
<div id="dailyCapacityBar" class="daily-capacity-bar" style="display: none;">
<div class="capacity-bar-header">
<span id="capacityDateLabel" class="fw-semibold"></span>
<span id="capacityHoursLabel" class="ms-2"></span>
</div>
<div class="capacity-bar-container">
<div id="capacityBarFill" class="capacity-bar-fill"></div>
</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" id="calendarLegend">
<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')">&times;</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')">&times;</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>
<button type="button" class="btn btn-outline-primary" id="duplicateEventBtn">
<i class="fas fa-copy me-1"></i>{{ _('Duplicate') }}
</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')">&times;</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>
<!-- Keyboard Shortcuts Help Modal -->
<div class="event-modal" id="keyboardShortcutsModal">
<div class="event-modal-content">
<div class="event-modal-header">
<h3><i class="fas fa-keyboard me-2 text-primary"></i>{{ _('Keyboard Shortcuts') }}</h3>
<button class="event-modal-close" onclick="closeModal('keyboardShortcutsModal')">&times;</button>
</div>
<div class="event-modal-body">
<div class="shortcuts-grid">
<div class="shortcut-section">
<h5 class="text-muted mb-3">{{ _('Navigation') }}</h5>
<div class="shortcut-item">
<kbd>T</kbd>
<span>{{ _('Jump to Today') }}</span>
</div>
<div class="shortcut-item">
<kbd>N</kbd>
<span>{{ _('Next Week/Month') }}</span>
</div>
<div class="shortcut-item">
<kbd>P</kbd>
<span>{{ _('Previous Week/Month') }}</span>
</div>
<div class="shortcut-item">
<kbd></kbd><kbd></kbd>
<span>{{ _('Navigate Days') }}</span>
</div>
</div>
<div class="shortcut-section">
<h5 class="text-muted mb-3">{{ _('Views') }}</h5>
<div class="shortcut-item">
<kbd>D</kbd>
<span>{{ _('Day View') }}</span>
</div>
<div class="shortcut-item">
<kbd>W</kbd>
<span>{{ _('Week View') }}</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd>
<span>{{ _('Month View') }}</span>
</div>
<div class="shortcut-item">
<kbd>A</kbd>
<span>{{ _('Agenda View') }}</span>
</div>
</div>
<div class="shortcut-section">
<h5 class="text-muted mb-3">{{ _('Actions') }}</h5>
<div class="shortcut-item">
<kbd>C</kbd>
<span>{{ _('Create New Entry') }}</span>
</div>
<div class="shortcut-item">
<kbd>F</kbd>
<span>{{ _('Focus Filter') }}</span>
</div>
<div class="shortcut-item">
<kbd>Shift</kbd>+<kbd>C</kbd>
<span>{{ _('Clear All Filters') }}</span>
</div>
<div class="shortcut-item">
<kbd>Esc</kbd>
<span>{{ _('Close Modal') }}</span>
</div>
</div>
<div class="shortcut-section">
<h5 class="text-muted mb-3">{{ _('Help') }}</h5>
<div class="shortcut-item">
<kbd>?</kbd>
<span>{{ _('Show This Help') }}</span>
</div>
</div>
</div>
</div>
<div class="event-modal-footer">
<button type="button" class="btn btn-primary" onclick="closeModal('keyboardShortcutsModal')">{{ _('Got it!') }}</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,
billable_only: false
};
let currentEventForDuplication = 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');
let events = json.events || [];
// Apply billable-only filter (client-side)
if (currentFilters.billable_only) {
events = events.filter(e => e.extendedProps.billable === true);
}
// Update total hours display
updateTotalHours(events);
// Update capacity bar for current view
updateCapacityDisplay(events, info);
// Update legend with actual projects
updateLegend(events);
success(events);
} catch(e) {
console.error('Calendar events error:', e);
showToast('{{ _("Failed to load events") }}', 'danger');
failure(e);
} finally {
// Clear loading state immediately
showLoading(false);
// Reset button states immediately
resetAllButtonStates();
}
},
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);
// Clear any loading states immediately after drag
resetAllButtonStates();
},
eventResize: async function(info) {
await updateEventTime(info.event);
// Clear any loading states immediately after resize
resetAllButtonStates();
}
});
calendar.render();
// Clear any stuck loading states on initialization
setTimeout(() => {
showLoading(false);
// Reset ALL buttons that might be stuck in loading state
resetAllButtonStates();
}, 100);
// Function to reset all button states
function resetAllButtonStates() {
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
// Remove any processing classes
btn.classList.remove('processing', 'loading', 'disabled');
// Restore original text if it was saved
const originalText = btn.getAttribute('data-original-text');
if (originalText) {
btn.innerHTML = originalText;
btn.removeAttribute('data-original-text');
}
// Re-enable button if it was disabled
btn.disabled = false;
// Remove any spinner icons
const spinner = btn.querySelector('.fa-spinner, .fa-spin');
if (spinner) {
spinner.remove();
}
});
}
// 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');
// Ensure loading state is cleared
showLoading(false);
});
// 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);
});
// Billable Only Filter
document.getElementById('filterBillableOnly').addEventListener('click', function() {
currentFilters.billable_only = !currentFilters.billable_only;
if (currentFilters.billable_only) {
this.classList.remove('btn-outline-success');
this.classList.add('btn-success');
showToast('{{ _("Showing billable entries only") }}', 'info');
} else {
this.classList.remove('btn-success');
this.classList.add('btn-outline-success');
}
calendar.refetchEvents();
});
// 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, billable_only: false };
// Reset billable button
const billableBtn = document.getElementById('filterBillableOnly');
billableBtn.classList.remove('btn-success');
billableBtn.classList.add('btn-outline-success');
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 (loader) {
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');
if (!taskSelect) return;
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;
} else {
taskSelect.innerHTML = '<option value="">{{ _("No task") }}</option>';
taskSelect.disabled = true;
}
} catch(e) {
console.error('Error loading tasks:', 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();
// Clear loading states immediately
resetAllButtonStates();
} catch(e) {
showToast(e.message || '{{ _("Failed to create entry") }}', 'danger');
// Clear loading states even on error
resetAllButtonStates();
}
}
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');
// Clear loading states immediately
resetAllButtonStates();
} catch(e) {
showToast(e.message || '{{ _("Failed to update entry") }}', 'danger');
calendar.refetchEvents();
// Clear loading states even on error
resetAllButtonStates();
}
}
function showEventDetails(event) {
const props = event.extendedProps;
const content = document.getElementById('eventDetailContent');
// Store current event for duplication
currentEventForDuplication = event;
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 () => {
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);
};
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();
// Clear loading states immediately
resetAllButtonStates();
} catch(e) {
showToast(e.message || '{{ _("Failed to delete entry") }}', 'danger');
// Clear loading states even on error
resetAllButtonStates();
}
}
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) {
const confirmed = await showConfirm(
'{{ _("Are you sure you want to delete this recurring block?") }}',
{
title: '{{ _("Delete Recurring Block") }}',
confirmText: '{{ _("Delete") }}',
cancelText: '{{ _("Cancel") }}',
variant: 'danger'
}
);
if (!confirmed) 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');
}
};
// Quick Win: Duplicate Event
async function duplicateEvent(event) {
try {
const props = event.extendedProps;
const duration = (new Date(event.end) - new Date(event.start)) / (1000 * 60 * 60); // hours
// Ask for new date/time
const newStartStr = prompt('{{ _("Enter new start time (YYYY-MM-DD HH:MM):") }}', '');
if (!newStartStr) return;
const newStart = new Date(newStartStr);
if (isNaN(newStart.getTime())) {
showToast('{{ _("Invalid date format") }}', 'danger');
return;
}
const newEnd = new Date(newStart.getTime() + (duration * 60 * 60 * 1000));
const data = {
project_id: props.project_id,
task_id: props.task_id,
start_time: newStart.toISOString().slice(0, 16),
end_time: newEnd.toISOString().slice(0, 16),
notes: props.notes,
tags: props.tags,
billable: props.billable
};
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 || 'Duplication failed');
}
showToast('{{ _("Entry duplicated successfully") }}', 'success');
closeModal('eventDetailModal');
calendar.refetchEvents();
// Clear loading states immediately
resetAllButtonStates();
} catch(e) {
showToast(e.message || '{{ _("Failed to duplicate entry") }}', 'danger');
// Clear loading states even on error
resetAllButtonStates();
}
}
// Quick Win: Update Total Hours Display
function updateTotalHours(events) {
const totalHours = events.reduce((sum, event) => {
return sum + (event.extendedProps.duration_hours || 0);
}, 0);
const displayEl = document.getElementById('totalHoursDisplay');
if (displayEl) {
displayEl.textContent = `${totalHours.toFixed(1)}h`;
}
}
// Quick Win: Update Legend with Actual Projects
function updateLegend(events) {
const legendEl = document.getElementById('calendarLegend');
if (!legendEl) return;
// Get unique projects from events
const projectMap = new Map();
events.forEach(event => {
const projectId = event.extendedProps.project_id;
const projectName = event.extendedProps.project_name;
const color = event.backgroundColor;
if (projectId && projectName && color) {
projectMap.set(projectId, {
name: projectName,
color: color
});
}
});
// Create legend items
const legendItems = Array.from(projectMap.values())
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
.slice(0, 8) // Limit to 8 projects to avoid clutter
.map(project => `
<div class="legend-item">
<div class="legend-color" style="background: ${project.color};"></div>
<span>${project.name}</span>
</div>
`).join('');
// Update legend content
const infoItem = `
<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>
`;
legendEl.innerHTML = legendItems + infoItem;
// Show/hide legend based on whether we have projects
if (projectMap.size === 0) {
legendEl.style.display = 'none';
} else {
legendEl.style.display = 'flex';
}
}
// Quick Win: Update Capacity Display
function updateCapacityDisplay(events, info) {
const view = calendar.view;
// Only show for day view for now
if (view.type !== 'timeGridDay') {
document.getElementById('dailyCapacityBar').style.display = 'none';
return;
}
// Calculate hours for the visible day
const dayStart = new Date(view.currentStart);
const dayEnd = new Date(view.currentEnd);
const dayEvents = events.filter(e => {
const eventStart = new Date(e.start);
return eventStart >= dayStart && eventStart < dayEnd;
});
const dayHours = dayEvents.reduce((sum, event) => {
return sum + (event.extendedProps.duration_hours || 0);
}, 0);
const capacity = 8.0; // Default capacity, can be made dynamic later
const percentage = Math.min((dayHours / capacity) * 100, 100);
// Update display
const barEl = document.getElementById('dailyCapacityBar');
const fillEl = document.getElementById('capacityBarFill');
const dateLabel = document.getElementById('capacityDateLabel');
const hoursLabel = document.getElementById('capacityHoursLabel');
if (barEl && fillEl && dateLabel && hoursLabel) {
barEl.style.display = 'block';
fillEl.style.width = `${percentage}%`;
// Color based on capacity
if (percentage < 90) {
fillEl.className = 'capacity-bar-fill capacity-ok';
} else if (percentage < 100) {
fillEl.className = 'capacity-bar-fill capacity-warning';
} else {
fillEl.className = 'capacity-bar-fill capacity-over';
}
dateLabel.textContent = dayStart.toLocaleDateString('{{ current_user.preferred_language or "en" }}', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
hoursLabel.textContent = `${dayHours.toFixed(1)}h / ${capacity.toFixed(0)}h (${percentage.toFixed(0)}%)`;
}
}
// Quick Win: Keyboard Shortcuts
document.addEventListener('keydown', function(e) {
// Don't trigger shortcuts when typing in input fields
if (e.target.matches('input, textarea, select')) {
// Allow Escape to work in modals even from inputs
if (e.key === 'Escape') {
const activeModal = document.querySelector('.event-modal.show');
if (activeModal) {
closeModal(activeModal.id);
}
}
return;
}
const key = e.key.toLowerCase();
switch(key) {
// Navigation
case 't':
calendar.today();
showToast('{{ _("Jumped to today") }}', 'info');
break;
case 'n':
calendar.next();
break;
case 'p':
calendar.prev();
break;
case 'arrowleft':
calendar.prev();
e.preventDefault();
break;
case 'arrowright':
calendar.next();
e.preventDefault();
break;
// Views
case 'd':
calendar.changeView('timeGridDay');
setActiveView('calendar');
document.getElementById('dayBtn').classList.add('active');
document.getElementById('weekBtn').classList.remove('active');
document.getElementById('monthBtn').classList.remove('active');
break;
case 'w':
calendar.changeView('timeGridWeek');
setActiveView('calendar');
document.getElementById('weekBtn').classList.add('active');
document.getElementById('dayBtn').classList.remove('active');
document.getElementById('monthBtn').classList.remove('active');
break;
case 'm':
calendar.changeView('dayGridMonth');
setActiveView('calendar');
document.getElementById('monthBtn').classList.add('active');
document.getElementById('dayBtn').classList.remove('active');
document.getElementById('weekBtn').classList.remove('active');
break;
case 'a':
setActiveView('agenda');
renderAgendaView();
break;
// Actions
case 'c':
if (e.shiftKey) {
// Shift+C: Clear filters
document.getElementById('clearFilters').click();
} else {
// C: Create new entry
document.getElementById('newEventBtn').click();
}
break;
case 'f':
// Focus filter input
document.getElementById('filterProject').focus();
break;
case '?':
// Show keyboard shortcuts help
openModal('keyboardShortcutsModal');
break;
case 'escape':
// Close active modal
const activeModal = document.querySelector('.event-modal.show');
if (activeModal) {
closeModal(activeModal.id);
}
break;
}
});
// Show toast message on page load to inform about shortcuts
setTimeout(() => {
showToast('{{ _("💡 Press ? to see keyboard shortcuts") }}', 'info');
}, 1000);
// Global function to reset all button states (can be called from console if needed)
window.resetCalendarButtons = resetAllButtonStates;
});
// 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 = '';
}
// Note: showToast is provided by the global toast-notifications.js
// No need to define it here - it's already available as window.showToast
</script>
{% endblock %}