mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 05:10:26 -05:00
8bb42ddd02
- Add RecurringInvoiceRepository and RecurringInvoiceService; refactor recurring_invoice model - Add GanttService and move gantt logic from route to service - Expand ReportingService and simplify reports route - Add license_utils and user license template/settings - Refactor routes to use scope_filter, api_responses, and services (API v1, timer, admin, invoices, etc.) - Extend invoice_service for recurring; cache and scope_filter utils; base/template updates
549 lines
19 KiB
HTML
549 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _('Analytics') }} - {{ app_name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-3">
|
|
{% from "components/ui.html" import page_header %}
|
|
<div class="row">
|
|
<div class="col-12">
|
|
{% set actions %}
|
|
<select id="timeRange" class="form-select form-select-sm" style="width: auto; font-size: 0.875rem;">
|
|
<option value="7">7d</option>
|
|
<option value="30" selected>30d</option>
|
|
<option value="90">90d</option>
|
|
</select>
|
|
<button id="refreshCharts" class="btn btn-outline-light btn-sm">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
{% endset %}
|
|
{{ page_header('fas fa-chart-line', _('Analytics'), _('Mobile insights overview'), actions) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Cards - Mobile Stacked -->
|
|
<div class="row mb-3">
|
|
<div class="col-6 mb-2">
|
|
<div class="card text-center h-100">
|
|
<div class="card-body py-2">
|
|
<i class="fas fa-clock fa-lg text-primary mb-1"></i>
|
|
<h6 class="text-primary mb-0" id="totalHours">-</h6>
|
|
<small class="text-muted">{{ _('Total Hours') }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 mb-2">
|
|
<div class="card text-center h-100">
|
|
<div class="card-body py-2">
|
|
<i class="fas fa-dollar-sign fa-lg text-success mb-1"></i>
|
|
<h6 class="text-success mb-0" id="billableHours">-</h6>
|
|
<small class="text-muted">{{ _('Billable') }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 mb-2">
|
|
<div class="card text-center h-100">
|
|
<div class="card-body py-2">
|
|
<i class="fas fa-project-diagram fa-lg text-info mb-1"></i>
|
|
<h6 class="text-info mb-0" id="activeProjects">-</h6>
|
|
<small class="text-muted">{{ _('Projects') }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 mb-2">
|
|
<div class="card text-center h-100">
|
|
<div class="card-body py-2">
|
|
<i class="fas fa-chart-line fa-lg text-warning mb-1"></i>
|
|
<h6 class="text-warning mb-0" id="avgDailyHours">-</h6>
|
|
<small class="text-muted">{{ _('Daily Avg') }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts - Mobile Stacked -->
|
|
<div class="row">
|
|
<div class="col-12 mb-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-chart-area"></i> {{ _('Daily Hours') }}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="chart-container" style="position: relative; height: 250px; width: 100%;">
|
|
<canvas id="dailyHoursChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 mb-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-chart-pie"></i> {{ _('Billable vs Non-Billable') }}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="chart-container" style="position: relative; height: 250px; width: 100%;">
|
|
<canvas id="billableChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 mb-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-chart-bar"></i> {{ _('Top Projects') }}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="chart-container" style="position: relative; height: 250px; width: 100%;">
|
|
<canvas id="projectChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 mb-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-clock"></i> {{ _('Hours by Time of Day') }}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="chart-container" style="position: relative; height: 250px; width: 100%;">
|
|
<canvas id="hourlyChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if current_user.is_admin %}
|
|
<div class="col-12 mb-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-users"></i> {{ _('User Performance') }}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="chart-container" style="position: relative; height: 250px;">
|
|
<canvas id="userChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</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-mobile">
|
|
{
|
|
"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-mobile');
|
|
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>
|
|
// Mobile-optimized chart defaults
|
|
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
|
Chart.defaults.font.size = 11;
|
|
Chart.defaults.color = '#64748b';
|
|
Chart.defaults.plugins.legend.position = 'bottom';
|
|
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
|
Chart.defaults.plugins.legend.labels.padding = 15;
|
|
Chart.defaults.plugins.legend.labels.boxWidth = 12;
|
|
|
|
// Mobile Analytics Dashboard Controller
|
|
class MobileAnalyticsDashboard {
|
|
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.loadHourlyChart(),
|
|
{% 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.loadHourlyChart(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: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
},
|
|
x: {
|
|
title: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
},
|
|
maxRotation: 45
|
|
}
|
|
}
|
|
},
|
|
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',
|
|
labels: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
},
|
|
x: {
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
},
|
|
maxRotation: 45
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
},
|
|
x: {
|
|
title: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
{% 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: false
|
|
},
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
},
|
|
x: {
|
|
ticks: {
|
|
font: {
|
|
size: 10
|
|
},
|
|
maxRotation: 45
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{% 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);
|
|
document.getElementById('avgDailyHours').textContent = avgDailyHours.toFixed(1);
|
|
|
|
// 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);
|
|
|
|
// 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 MobileAnalyticsDashboard();
|
|
});
|
|
</script>
|
|
{% endblock %}
|