Files
TimeTracker/app/templates/analytics/dashboard.html
T
Dries Peeters 54533ec95e feat(reports): add Time Entries report with Excel/CSV export (Discussion #463)
- Add /reports/time-entries page listing all time entries (billed and unbilled)
- Columns: Date, Start, Stop, Duration, Project, Task, Notes, Billed, Client
- Filters: date range, user, project, client, task, billed (all/yes/no)
- Export to Excel and CSV with same filters; add Billed column to excel export
- Resolve client from entry.client or project.client in export
- Add Time Entries Report card to Reports index
2026-02-13 20:56:07 +01:00

710 lines
26 KiB
HTML

{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('Analytics Dashboard') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Analytics')}
] %}
{% set analytics_actions %}
<div class="flex items-center gap-2">
<select id="timeRange" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark">
<option value="7">{{ _('Last 7 days') }}</option>
<option value="30" selected>{{ _('Last 30 days') }}</option>
<option value="90">{{ _('Last 90 days') }}</option>
<option value="365">{{ _('Last year') }}</option>
</select>
<button id="refreshCharts" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-sync-alt mr-2"></i> {{ _('Refresh') }}
</button>
</div>
{% endset %}
{{ page_header(
icon_class='fas fa-chart-line',
title_text=_('Analytics Dashboard'),
subtitle_text=_('Key metrics and insights about your time tracking'),
breadcrumbs=breadcrumbs,
actions_html=analytics_actions
) }}
<div class="container-fluid">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
<h4 class="text-primary" id="totalHours">-</h4>
<p class="text-muted mb-0">{{ _('Total Hours') }}</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
<h4 class="text-success" id="billableHours">-</h4>
<p class="text-muted mb-0">{{ _('Billable Hours') }}</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-info mb-2"></i>
<h4 class="text-info" id="activeProjects">-</h4>
<p class="text-muted mb-0">{{ _('Active Projects') }}</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-chart-line fa-2x text-warning mb-2"></i>
<h4 class="text-warning" id="avgDailyHours">-</h4>
<p class="text-muted mb-0">{{ _('Avg Daily Hours') }}</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-business-time fa-2x text-info mb-2"></i>
<h4 class="text-info" id="overtimeSummary">-</h4>
<p class="text-muted mb-0">{{ _('Regular') }} / {{ _('Overtime') }}</p>
<p class="text-muted small mb-0" id="overtimeDaysLabel"></p>
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="row mb-4">
<div class="col-lg-8 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-area"></i> {{ _('Daily Hours Trend') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="dailyHoursChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie"></i> {{ _('Billable vs Non-Billable') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="billableChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="row mb-4">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> {{ _('Hours by Project') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="projectChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-line"></i> {{ _('Weekly Trends') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="weeklyTrendsChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Overtime Chart -->
<div class="row mb-4">
<div class="col-12 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-business-time"></i> {{ _('Daily Regular vs Overtime') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="overtimeChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="row mb-4">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-clock"></i> {{ _('Hours by Time of Day') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="hourlyChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> {{ _('Project Efficiency') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="efficiencyChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- User Performance Chart (Admin Only) -->
{% if current_user.is_admin %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-users"></i> {{ _('User Performance') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px;">
<canvas id="userChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Loading Spinner -->
<div id="loadingSpinner" class="position-fixed top-50 start-50 translate-middle" style="display: none; z-index: 9999;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ _('Loading...') }}</span>
</div>
</div>
{% endblock %}
{% block extra_head %}
<!-- Chart.js library -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script type="application/json" id="i18n-json-analytics-dashboard">
{
"error_loading_charts": {{ _('Failed to load charts. Please try again.')|tojson }},
"error_refreshing_charts": {{ _('Failed to refresh charts. Please try again.')|tojson }},
"hours_label": {{ _('Hours')|tojson }},
"date_label": {{ _('Date')|tojson }},
"hour_of_day_label": {{ _('Hour of Day')|tojson }},
"revenue_label": {{ _('Revenue')|tojson }}
}
</script>
<script>
// Ensure i18n_analytics is always defined globally
window.i18n_analytics = (function(){
try {
var el = document.getElementById('i18n-json-analytics-dashboard');
return el ? JSON.parse(el.textContent) : {
"error_loading_charts": "Failed to load charts. Please try again.",
"error_refreshing_charts": "Failed to refresh charts. Please try again.",
"hours_label": "Hours",
"date_label": "Date",
"hour_of_day_label": "Hour of Day",
"revenue_label": "Revenue"
};
} catch (e) {
return {
"error_loading_charts": "Failed to load charts. Please try again.",
"error_refreshing_charts": "Failed to refresh charts. Please try again.",
"hours_label": "Hours",
"date_label": "Date",
"hour_of_day_label": "Hour of Day",
"revenue_label": "Revenue"
};
}
})();
// Also make it available as a local variable for backward compatibility
var i18n_analytics = window.i18n_analytics;
</script>
{% endblock %}
{% block extra_js %}
<script>
// Global chart instances
let charts = {};
// Chart.js global defaults
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.color = '#64748b';
Chart.defaults.plugins.legend.position = 'bottom';
Chart.defaults.plugins.legend.labels.usePointStyle = true;
Chart.defaults.plugins.legend.labels.padding = 20;
// Analytics Dashboard Controller
class AnalyticsDashboard {
constructor() {
this.timeRange = 30;
this.charts = {};
this.init();
}
init() {
this.bindEvents();
this.loadCharts();
this.updateSummaryCards();
}
bindEvents() {
document.getElementById('timeRange').addEventListener('change', (e) => {
this.timeRange = parseInt(e.target.value);
this.refreshAllCharts();
});
document.getElementById('refreshCharts').addEventListener('click', () => {
this.refreshAllCharts();
});
}
showLoading() {
document.getElementById('loadingSpinner').style.display = 'block';
}
hideLoading() {
document.getElementById('loadingSpinner').style.display = 'none';
}
async loadCharts() {
this.showLoading();
try {
await Promise.all([
this.loadDailyHoursChart(),
this.loadBillableChart(),
this.loadProjectChart(),
this.loadWeeklyTrendsChart(),
this.loadHourlyChart(),
this.loadEfficiencyChart(),
this.loadOvertimeChart(),
{% if current_user.is_admin %}
this.loadUserChart(),
{% endif %}
]);
} catch (error) {
console.error('Error loading charts:', error);
this.showError(i18n_analytics.error_loading_charts || 'Failed to load charts. Please try again.');
} finally {
this.hideLoading();
}
}
async refreshAllCharts() {
this.showLoading();
try {
await Promise.all([
this.loadDailyHoursChart(true),
this.loadBillableChart(true),
this.loadProjectChart(true),
this.loadWeeklyTrendsChart(true),
this.loadHourlyChart(true),
this.loadEfficiencyChart(true),
this.loadOvertimeChart(true),
{% if current_user.is_admin %}
this.loadUserChart(true),
{% endif %}
]);
this.updateSummaryCards();
} catch (error) {
console.error('Error refreshing charts:', error);
this.showError(i18n_analytics.error_refreshing_charts || 'Failed to refresh charts. Please try again.');
} finally {
this.hideLoading();
}
}
async loadDailyHoursChart(refresh = false) {
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.dailyHours) {
this.charts.dailyHours.destroy();
}
const ctx = document.getElementById('dailyHoursChart').getContext('2d');
this.charts.dailyHours = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
},
x: {
title: {
display: true,
text: (i18n_analytics.date_label || 'Date')
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
async loadBillableChart(refresh = false) {
const response = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.billable) {
this.charts.billable.destroy();
}
const ctx = document.getElementById('billableChart').getContext('2d');
this.charts.billable = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
async loadProjectChart(refresh = false) {
const response = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.project) {
this.charts.project.destroy();
}
const ctx = document.getElementById('projectChart').getContext('2d');
this.charts.project = new Chart(ctx, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
}
}
}
});
}
async loadWeeklyTrendsChart(refresh = false) {
const response = await fetch(`/api/analytics/weekly-trends?weeks=${Math.min(12, Math.ceil(this.timeRange / 7))}`);
const data = await response.json();
if (refresh && this.charts.weeklyTrends) {
this.charts.weeklyTrends.destroy();
}
const ctx = document.getElementById('weeklyTrendsChart').getContext('2d');
this.charts.weeklyTrends = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
}
}
}
});
}
async loadHourlyChart(refresh = false) {
const response = await fetch(`/api/analytics/hours-by-hour?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.hourly) {
this.charts.hourly.destroy();
}
const ctx = document.getElementById('hourlyChart').getContext('2d');
this.charts.hourly = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
},
x: {
title: {
display: true,
text: (i18n_analytics.hour_of_day_label || 'Hour of Day')
}
}
}
}
});
}
async loadEfficiencyChart(refresh = false) {
const response = await fetch(`/api/analytics/project-efficiency?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.efficiency) {
this.charts.efficiency.destroy();
}
const ctx = document.getElementById('efficiencyChart').getContext('2d');
this.charts.efficiency = new Chart(ctx, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: (i18n_analytics.revenue_label || 'Revenue')
},
grid: {
drawOnChartArea: false,
},
}
}
}
});
}
async loadOvertimeChart(refresh = false) {
const response = await fetch(`/api/analytics/overtime?days=${this.timeRange}`);
const data = await response.json();
if (!response.ok) return;
const summary = data.summary || {};
const summaryEl = document.getElementById('overtimeSummary');
const daysLabelEl = document.getElementById('overtimeDaysLabel');
if (summaryEl) {
summaryEl.textContent = (summary.total_regular_hours || 0).toFixed(1) + 'h / ' + (summary.total_overtime_hours || 0).toFixed(1) + 'h';
}
if (daysLabelEl && data.users && data.users.length > 0) {
const daysWithOt = data.users.reduce((s, u) => s + (u.days_with_overtime || 0), 0);
daysLabelEl.textContent = daysWithOt > 0 ? (daysWithOt + ' ' + (i18n_analytics.days_overtime || 'days with overtime')) : '';
}
const daily = data.daily_breakdown || [];
if (refresh && this.charts.overtime) {
this.charts.overtime.destroy();
}
const ctx = document.getElementById('overtimeChart');
if (!ctx) return;
const chartData = {
labels: daily.map(d => d.date),
datasets: [
{ label: (i18n_analytics.regular_hours || 'Regular'), data: daily.map(d => d.regular_hours), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderColor: '#3b82f6', borderWidth: 1 },
{ label: (i18n_analytics.overtime_hours || 'Overtime'), data: daily.map(d => d.overtime_hours), backgroundColor: 'rgba(245, 158, 11, 0.8)', borderColor: '#f59e0b', borderWidth: 1 }
]
};
this.charts.overtime = new Chart(ctx.getContext('2d'), {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { stacked: true, ticks: { maxRotation: 45, minRotation: 45 } },
y: { stacked: true, beginAtZero: true, title: { display: true, text: (i18n_analytics.hours_label || 'Hours') } }
}
}
});
}
{% if current_user.is_admin %}
async loadUserChart(refresh = false) {
const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`);
const data = await response.json();
if (refresh && this.charts.user) {
this.charts.user.destroy();
}
const ctx = document.getElementById('userChart').getContext('2d');
this.charts.user = new Chart(ctx, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: (i18n_analytics.hours_label || 'Hours')
}
}
}
}
});
}
{% endif %}
async updateSummaryCards() {
try {
// Get summary data from daily hours chart
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
const data = await response.json();
const totalHours = data.datasets[0].data.reduce((sum, hours) => sum + hours, 0);
const avgDailyHours = totalHours / data.datasets[0].data.length;
document.getElementById('totalHours').textContent = totalHours.toFixed(1) + 'h';
document.getElementById('avgDailyHours').textContent = avgDailyHours.toFixed(1) + 'h';
// Get billable data
const billableResponse = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
const billableData = await billableResponse.json();
const billableHours = billableData.datasets[0].data[0];
document.getElementById('billableHours').textContent = billableHours.toFixed(1) + 'h';
// Get project count
const projectResponse = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
const projectData = await projectResponse.json();
document.getElementById('activeProjects').textContent = projectData.labels.length;
} catch (error) {
console.error('Error updating summary cards:', error);
}
}
showError(message) {
// Use the new toast notification system
if (window.toastManager) {
window.toastManager.error(message, 'Error', 5000);
} else {
// Fallback to console if toast system not available
console.error('Analytics error:', message);
}
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new AnalyticsDashboard();
});
</script>
{% endblock %}