Files
TimeTracker/app/templates/analytics/dashboard_improved.html
T
Dries Peeters 8d4ec0e25f feat(payments): add analytics integration and improve UI consistency
## Payment Analytics Integration
- Add 5 new API endpoints for payment metrics:
  - /api/analytics/payments-over-time - trend visualization
  - /api/analytics/payments-by-status - status distribution
  - /api/analytics/payments-by-method - method breakdown
  - /api/analytics/payment-summary - statistics with period comparison
  - /api/analytics/revenue-vs-payments - collection rate tracking
- Integrate payment data into analytics dashboard with 4 new charts
- Add payment metrics to reports page (total, count, fees, net received)
- Update summary endpoint to include payment statistics

## UI/UX Improvements
- Standardize form styling across all payment templates
  - Replace inconsistent Tailwind classes with form-input utility
  - Update card backgrounds to use card-light/card-dark
  - Fix label spacing to match application patterns
  - Ensure consistent border colors and backgrounds
- Replace browser confirm() with system-wide modal for payment deletion
  - Consistent danger variant with warning icon
  - Keyboard support (Enter/Escape)
  - Dark mode compatible
  - Clear messaging about impact on invoice status

## Technical Changes
- Import Payment and Invoice models in analytics and reports routes
- Add proper admin/user scoping for payment queries
- Maintain responsive design across all new components

Closes payment tracking phase 2 (analytics & polish)
2025-10-27 13:38:07 +01:00

701 lines
36 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Analytics Dashboard') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold"><i class="fas fa-chart-line mr-2"></i>{{ _('Analytics Dashboard') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Key metrics and actionable insights') }}</p>
</div>
<div class="mt-3 md:mt-0 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="exportData" class="px-3 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-download mr-1"></i>{{ _('Export') }}
</button>
<button id="refreshCharts" class="px-3 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90">
<i class="fas fa-sync-alt mr-1"></i>{{ _('Refresh') }}
</button>
</div>
</div>
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-clock text-primary"></i>
<span id="totalHoursChange" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700">+0%</span>
</div>
<div class="text-2xl font-semibold text-primary" id="totalHours">-</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('vs previous period') }}</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-dollar-sign text-green-600"></i>
<span id="billableHoursChange" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700">+0%</span>
</div>
<div class="text-2xl font-semibold text-green-600" id="billableHours">-</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Billable Hours') }}</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark"><span id="billablePercentage">0</span>% {{ _('of total') }}</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-sack-dollar text-amber-500"></i>
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-sky-100 text-sky-700"><i class="fas fa-info-circle mr-1"></i>Info</span>
</div>
<div class="text-2xl font-semibold text-amber-600" id="totalRevenue">-</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Potential Revenue') }}</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('Avg rate:') }} <span id="avgHourlyRate">-</span></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-chart-line text-sky-600"></i>
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary"><i class="fas fa-chart-bar mr-1"></i>{{ _('Trend') }}</span>
</div>
<div class="text-2xl font-semibold text-sky-600" id="avgDailyHours">-</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Avg Daily Hours') }}</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark"><span id="activeProjects">0</span> {{ _('active projects') }}</div>
</div>
</div>
<!-- Insights -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold"><i class="fas fa-lightbulb text-amber-500 mr-2"></i>{{ _('Insights & Recommendations') }}</h2>
</div>
<div id="insightsContainer" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="col-span-1 text-center py-6">
<i class="fas fa-circle-notch fa-spin text-primary"></i>
</div>
</div>
</div>
<!-- Charts: Daily Hours + Billable -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold"><i class="fas fa-chart-area text-primary mr-2"></i>{{ _('Daily Hours Trend') }}</h3>
<label class="flex items-center text-sm gap-2">
<input class="rounded" type="checkbox" id="showCumulativeToggle">
<span>{{ _('Cumulative') }}</span>
</label>
</div>
<div class="relative h-[300px]">
<canvas id="dailyHoursChart"></canvas>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-pie text-green-600 mr-2"></i>{{ _('Billable Distribution') }}</h3>
<div class="relative h-[300px]">
<canvas id="billableChart"></canvas>
</div>
</div>
</div>
<!-- Charts: Tasks & Revenue -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-tasks text-sky-600 mr-2"></i>{{ _('Task Status Overview') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-center">
<div class="relative h-[250px]"><canvas id="taskStatusChart"></canvas></div>
<div class="space-y-3">
<div>
<div class="text-lg font-semibold text-green-600" id="tasksCompleted">0</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Tasks Completed') }}</div>
</div>
<div>
<div class="text-lg font-semibold text-primary" id="tasksInProgress">0</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('In Progress') }}</div>
</div>
<div>
<div class="text-lg font-semibold text-text-muted-light dark:text-text-muted-dark" id="tasksTodo">0</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('To Do') }}</div>
</div>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-bar text-amber-500 mr-2"></i>{{ _('Revenue by Project') }}</h3>
<div class="relative h-[300px]"><canvas id="revenueChart"></canvas></div>
</div>
</div>
<!-- Charts: Payment Analytics -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-money-bill-wave text-green-600 mr-2"></i>{{ _('Payments Over Time') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentsOverTimeChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-pie text-emerald-600 mr-2"></i>{{ _('Payment Status') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentStatusChart"></canvas></div>
</div>
</div>
<!-- Charts: Payment Methods & Revenue Comparison -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-credit-card text-blue-600 mr-2"></i>{{ _('Payment Methods') }}</h3>
<div class="relative h-[300px]"><canvas id="paymentMethodChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-balance-scale text-indigo-600 mr-2"></i>{{ _('Revenue vs Payments') }}</h3>
<div class="relative h-[300px]">
<canvas id="revenueVsPaymentsChart"></canvas>
</div>
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Potential Revenue') }}</div>
<div class="text-lg font-semibold text-amber-600" id="potentialRevenue">-</div>
</div>
<div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Collection Rate') }}</div>
<div class="text-lg font-semibold text-green-600" id="collectionRate">-</div>
</div>
</div>
</div>
</div>
<!-- Charts: Hours by Project & Weekly Trends -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-bar text-primary mr-2"></i>{{ _('Hours by Project') }}</h3>
<div class="relative h-[300px]"><canvas id="projectChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-chart-line text-purple mr-2"></i>{{ _('Weekly Trends') }}</h3>
<div class="relative h-[300px]"><canvas id="weeklyTrendsChart"></canvas></div>
</div>
</div>
<!-- Charts: Hours by Time of Day & Completion Rate -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-clock text-sky-600 mr-2"></i>{{ _('Hours by Time of Day') }}</h3>
<div class="relative h-[300px]"><canvas id="hourlyChart"></canvas></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="font-semibold mb-3"><i class="fas fa-percentage text-green-600 mr-2"></i>{{ _('Project Completion Rate') }}</h3>
<div class="relative h-[300px]"><canvas id="completionRateChart"></canvas></div>
</div>
</div>
{% if current_user.is_admin %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h3 class="font-semibold mb-3"><i class="fas fa-users text-primary mr-2"></i>{{ _('User Performance') }}</h3>
<div class="relative h-[300px]"><canvas id="userChart"></canvas></div>
</div>
{% endif %}
<!-- Loading overlay -->
<div id="loadingSpinner" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div class="flex items-center gap-3 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark border border-border-light dark:border-border-dark px-4 py-3 rounded-lg shadow">
<i class="fas fa-circle-notch fa-spin text-primary"></i>
<span>{{ _('Loading...') }}</span>
</div>
</div>
{% endblock %}
{% block extra_css %}{% endblock %}
{% block scripts_extra %}
<!-- Chart.js -->
<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 }},
"billable_label": {{ _('Billable')|tojson }},
"non_billable_label": {{ _('Non-Billable')|tojson }},
"completed_label": {{ _('Completed')|tojson }},
"in_progress_label": {{ _('In Progress')|tojson }},
"todo_label": {{ _('To Do')|tojson }},
"review_label": {{ _('Review')|tojson }},
"cancelled_label": {{ _('Cancelled')|tojson }},
"completion_rate_label": {{ _('Completion Rate (%)')|tojson }}
}
</script>
<script>
// Ensure i18n_analytics is globally available
window.i18n_analytics = (function(){
try { var el = document.getElementById('i18n-json-analytics-dashboard'); return el ? JSON.parse(el.textContent) : {}; } catch(e){ return {}; }
})();
var i18n_analytics = window.i18n_analytics;
// Chart.js 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;
class EnhancedAnalyticsDashboard {
constructor() {
this.timeRange = 30;
this.charts = {};
this.summaryData = null;
this.currency = 'EUR';
this.init();
}
init() {
this.bindEvents();
this.loadAllData();
}
bindEvents() {
const timeRangeEl = document.getElementById('timeRange');
timeRangeEl && timeRangeEl.addEventListener('change', (e) => {
this.timeRange = parseInt(e.target.value);
this.refreshAllCharts();
});
const refreshBtn = document.getElementById('refreshCharts');
refreshBtn && refreshBtn.addEventListener('click', () => this.refreshAllCharts());
const exportBtn = document.getElementById('exportData');
exportBtn && exportBtn.addEventListener('click', () => this.exportData());
const cumToggle = document.getElementById('showCumulativeToggle');
cumToggle && cumToggle.addEventListener('change', (e) => this.toggleCumulative(e.target.checked));
}
showLoading() {
const el = document.getElementById('loadingSpinner');
if (el) el.classList.remove('hidden');
}
hideLoading() {
const el = document.getElementById('loadingSpinner');
if (el) el.classList.add('hidden');
}
async loadAllData() {
this.showLoading();
try {
await Promise.all([
this.loadSummaryCards(),
this.loadInsights(),
this.loadCharts()
]);
} catch (error) {
console.error('Error loading dashboard:', error);
this.showError(i18n_analytics.error_loading_charts || 'Failed to load analytics');
} finally {
this.hideLoading();
}
}
async refreshAllCharts() {
this.showLoading();
try {
Object.values(this.charts).forEach(chart => { if (chart) chart.destroy(); });
this.charts = {};
await this.loadAllData();
} catch (error) {
console.error('Error refreshing charts:', error);
this.showError(i18n_analytics.error_refreshing_charts || 'Failed to refresh charts');
} finally {
this.hideLoading();
}
}
async loadSummaryCards() {
try {
const [summaryResponse, revenueResponse] = await Promise.all([
fetch(`/api/analytics/summary-with-comparison?days=${this.timeRange}`),
fetch(`/api/analytics/revenue-metrics?days=${this.timeRange}`)
]);
const summaryData = await summaryResponse.json();
const revenueData = await revenueResponse.json();
this.summaryData = summaryData;
this.currency = revenueData.currency || 'EUR';
// totals
document.getElementById('totalHours').textContent = `${summaryData.total_hours}h`;
this.updateChangeIndicator('totalHoursChange', summaryData.total_hours_change);
document.getElementById('billableHours').textContent = `${summaryData.billable_hours}h`;
this.updateChangeIndicator('billableHoursChange', summaryData.billable_hours_change);
document.getElementById('billablePercentage').textContent = summaryData.billable_percentage;
document.getElementById('totalRevenue').textContent = `${this.currency} ${this.formatNumber(revenueData.total_revenue)}`;
document.getElementById('avgHourlyRate').textContent = `${this.currency} ${this.formatNumber(revenueData.avg_hourly_rate)}/h`;
document.getElementById('avgDailyHours').textContent = `${summaryData.avg_daily_hours}h`;
document.getElementById('activeProjects').textContent = summaryData.active_projects;
} catch (error) {
console.error('Error loading summary cards:', error);
}
}
updateChangeIndicator(elementId, change) {
const element = document.getElementById(elementId);
if (!element) return;
const isPositive = Number(change) >= 0;
element.className = `inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`;
element.innerHTML = `<i class="fas fa-arrow-${isPositive ? 'up' : 'down'} mr-1"></i>${Math.abs(change || 0).toFixed(1)}%`;
}
async loadInsights() {
try {
const response = await fetch(`/api/analytics/insights?days=${this.timeRange}`);
const data = await response.json();
const container = document.getElementById('insightsContainer');
if (!data.insights || data.insights.length === 0) {
container.innerHTML = `
<div class="col-span-full text-center py-3 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-check-circle text-green-600 mr-1"></i>
${i18n_analytics.no_insights || 'Everything looks good! Keep up the great work.'}
</div>`;
return;
}
container.innerHTML = data.insights.map(insight => `
<div class="bg-background-light dark:bg-background-dark/40 p-4 rounded border border-border-light dark:border-border-dark">
<div class="flex items-start gap-3">
<i class="${insight.icon} text-xl"></i>
<div>
<div class="font-semibold mb-1">${insight.title}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">${insight.message}</div>
</div>
</div>
</div>`).join('');
} catch (error) {
console.error('Error loading insights:', error);
}
}
async loadCharts() {
const tasks = [
this.loadDailyHoursChart(),
this.loadBillableChart(),
this.loadTaskStatusChart(),
this.loadRevenueChart(),
this.loadPaymentsOverTimeChart(),
this.loadPaymentStatusChart(),
this.loadPaymentMethodChart(),
this.loadRevenueVsPaymentsChart(),
this.loadProjectChart(),
this.loadWeeklyTrendsChart(),
this.loadHourlyChart(),
this.loadCompletionRateChart()
];
{% if current_user.is_admin %}
tasks.push(this.loadUserChart());
{% endif %}
await Promise.all(tasks);
}
async loadDailyHoursChart() {
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('dailyHoursChart').getContext('2d');
this.charts.dailyHours = new Chart(ctx, {
type: 'line', data, options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: {
mode: 'index', intersect: false,
backgroundColor: 'rgba(255,255,255,0.9)', titleColor: '#111827', bodyColor: '#6b7280', borderColor: '#e5e7eb', borderWidth: 1, padding: 12,
callbacks: { label: (c) => `${c.parsed.y.toFixed(1)}h` }
}},
scales: { y: { beginAtZero: true, title: { display: true, text: i18n_analytics.hours_label || 'Hours' }, grid: { color: '#f3f4f6' } }, x: { title: { display: true, text: i18n_analytics.date_label || 'Date' }, grid: { display: false }, ticks: { maxRotation: 45, minRotation: 45 } } },
interaction: { intersect: false, mode: 'index' }
}
});
}
async loadBillableChart() {
const response = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('billableChart').getContext('2d');
this.charts.billable = new Chart(ctx, { type: 'doughnut', data, options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' }, tooltip: {
backgroundColor: 'rgba(255,255,255,0.9)', titleColor: '#111827', bodyColor: '#6b7280', borderColor: '#e5e7eb', borderWidth: 1, padding: 12,
callbacks: { label: (ctx) => {
const label = ctx.label || ''; const value = ctx.parsed || 0; const total = ctx.dataset.data.reduce((a,b)=>a+b,0); const pct = total>0 ? ((value/total)*100).toFixed(1) : 0; return `${label}: ${value.toFixed(1)}h (${pct}%)`;
} }
}}
}});
}
async loadTaskStatusChart() {
const response = await fetch(`/api/analytics/task-completion?days=${this.timeRange}`);
let data = {};
try {
data = await response.json();
} catch (e) {
data = {};
}
const status = (data && typeof data === 'object' && data.status_breakdown && typeof data.status_breakdown === 'object') ? data.status_breakdown : {};
document.getElementById('tasksCompleted').textContent = (status.done || 0);
document.getElementById('tasksInProgress').textContent = (status.in_progress || 0);
document.getElementById('tasksTodo').textContent = (status.todo || 0);
const ctx = document.getElementById('taskStatusChart').getContext('2d');
this.charts.taskStatus = new Chart(ctx, { type: 'doughnut', data: {
labels: [i18n_analytics.completed_label || 'Completed', i18n_analytics.in_progress_label || 'In Progress', i18n_analytics.todo_label || 'To Do', i18n_analytics.review_label || 'Review'],
datasets: [{ data: [status.done||0, status.in_progress||0, status.todo||0, status.review||0], backgroundColor: ['#10b981','#3b82f6','#94a3b8','#f59e0b'], borderWidth: 2, borderColor: '#fff' }]
}, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { padding: 10, font: { size: 10 } } } } } });
}
async loadRevenueChart() {
const response = await fetch(`/api/analytics/revenue-metrics?days=${this.timeRange}`);
const data = await response.json();
if (!data.project_labels || data.project_labels.length === 0) {
const ctx = document.getElementById('revenueChart').getContext('2d');
ctx.font = '14px sans-serif'; ctx.fillStyle = '#6b7280'; ctx.textAlign = 'center'; ctx.fillText('No revenue data available', ctx.canvas.width/2, ctx.canvas.height/2); return;
}
const ctx = document.getElementById('revenueChart').getContext('2d');
this.charts.revenue = new Chart(ctx, { type: 'bar', data: { labels: data.project_labels, datasets: [{ label: `${i18n_analytics.revenue_label || 'Revenue'} (${this.currency})`, data: data.project_revenue, backgroundColor: 'rgba(245, 158, 11, 0.8)', borderColor: '#f59e0b', borderWidth: 2 }] }, options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(255,255,255,0.9)', titleColor: '#111827', bodyColor: '#6b7280', borderColor: '#e5e7eb', borderWidth: 1, padding: 12, callbacks: { label: (c) => `${this.currency} ${this.formatNumber(c.parsed.y)}` } } },
scales: { y: { beginAtZero: true, title: { display: true, text: `${i18n_analytics.revenue_label || 'Revenue'} (${this.currency})` } }, x: { ticks: { maxRotation: 45, minRotation: 45 } } }
}});
}
async loadProjectChart() {
const response = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('projectChart').getContext('2d');
this.charts.project = new Chart(ctx, { type: 'bar', data, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(255,255,255,0.9)', titleColor: '#111827', bodyColor: '#6b7280', borderColor: '#e5e7eb', borderWidth: 1, padding: 12 } }, scales: { y: { beginAtZero: true, title: { display: true, text: i18n_analytics.hours_label || 'Hours' } }, x: { ticks: { maxRotation: 45, minRotation: 45 } } } }});
}
async loadWeeklyTrendsChart() {
const response = await fetch(`/api/analytics/weekly-trends?weeks=${Math.min(12, Math.ceil(this.timeRange / 7))}`);
const data = await response.json();
const ctx = document.getElementById('weeklyTrendsChart').getContext('2d');
this.charts.weeklyTrends = new Chart(ctx, { type: 'line', 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() {
const response = await fetch(`/api/analytics/hours-by-hour?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('hourlyChart').getContext('2d');
this.charts.hourly = new Chart(ctx, { type: 'bar', 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 loadCompletionRateChart() {
const response = await fetch(`/api/analytics/task-completion?days=${this.timeRange}`);
const data = await response.json();
if (!data.project_labels || data.project_labels.length === 0) return;
const ctx = document.getElementById('completionRateChart').getContext('2d');
this.charts.completionRate = new Chart(ctx, { type: 'bar', data: { labels: data.project_labels, datasets: [{ label: i18n_analytics.completion_rate_label || 'Completion Rate (%)', data: data.project_completion_rates, backgroundColor: 'rgba(16, 185, 129, 0.8)', borderColor: '#10b981', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: '%' } }, x: { ticks: { maxRotation: 45, minRotation: 45 } } } } });
}
async loadPaymentsOverTimeChart() {
const response = await fetch(`/api/analytics/payments-over-time?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('paymentsOverTimeChart').getContext('2d');
this.charts.paymentsOverTime = new Chart(ctx, {
type: 'line',
data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.9)',
titleColor: '#111827',
bodyColor: '#6b7280',
borderColor: '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (c) => `${this.currency} ${this.formatNumber(c.parsed.y)}`
}
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: `Amount (${this.currency})` },
grid: { color: '#f3f4f6' }
},
x: {
title: { display: true, text: 'Date' },
grid: { display: false },
ticks: { maxRotation: 45, minRotation: 45 }
}
}
}
});
}
async loadPaymentStatusChart() {
const response = await fetch(`/api/analytics/payments-by-status?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('paymentStatusChart').getContext('2d');
this.charts.paymentStatus = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.labels,
datasets: [{
data: data.amount_dataset.data,
backgroundColor: data.amount_dataset.backgroundColor,
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.9)',
titleColor: '#111827',
bodyColor: '#6b7280',
borderColor: '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (ctx) => {
const label = ctx.label || '';
const value = ctx.parsed || 0;
return `${label}: ${this.currency} ${this.formatNumber(value)}`;
}
}
}
}
}
});
}
async loadPaymentMethodChart() {
const response = await fetch(`/api/analytics/payments-by-method?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('paymentMethodChart').getContext('2d');
this.charts.paymentMethod = new Chart(ctx, {
type: 'bar',
data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.9)',
titleColor: '#111827',
bodyColor: '#6b7280',
borderColor: '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (c) => `${this.currency} ${this.formatNumber(c.parsed.y)}`
}
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: `Amount (${this.currency})` }
},
x: {
ticks: { maxRotation: 45, minRotation: 45 }
}
}
}
});
}
async loadRevenueVsPaymentsChart() {
const response = await fetch(`/api/analytics/revenue-vs-payments?days=${this.timeRange}`);
const data = await response.json();
// Update summary stats
document.getElementById('potentialRevenue').textContent = `${this.currency} ${this.formatNumber(data.potential_revenue)}`;
document.getElementById('collectionRate').textContent = `${data.collection_rate}%`;
const ctx = document.getElementById('revenueVsPaymentsChart').getContext('2d');
this.charts.revenueVsPayments = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.labels,
datasets: [{
data: data.data,
backgroundColor: ['#10b981', '#f59e0b'],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.9)',
titleColor: '#111827',
bodyColor: '#6b7280',
borderColor: '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (ctx) => {
const label = ctx.label || '';
const value = ctx.parsed || 0;
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return `${label}: ${this.currency} ${this.formatNumber(value)} (${pct}%)`;
}
}
}
}
}
});
}
{% if current_user.is_admin %}
async loadUserChart() {
const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`);
const data = await response.json();
const ctx = document.getElementById('userChart').getContext('2d');
this.charts.user = new Chart(ctx, { type: 'bar', data, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, title: { display: true, text: i18n_analytics.hours_label || 'Hours' } } } } });
}
{% endif %}
toggleCumulative(enabled) { console.log('Cumulative view:', enabled); }
exportData() {
if (!this.summaryData) return;
const csvContent = [
['Metric','Value'],
['Total Hours', this.summaryData.total_hours],
['Billable Hours', this.summaryData.billable_hours],
['Average Daily Hours', this.summaryData.avg_daily_hours],
['Active Projects', this.summaryData.active_projects],
['Billable Percentage', this.summaryData.billable_percentage + '%']
].map(r => r.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `analytics-${new Date().toISOString().split('T')[0]}.csv`; a.click(); window.URL.revokeObjectURL(url);
}
formatNumber(num) { return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); }
showError(message) {
if (window.toastManager) { window.toastManager.error(message, 'Error', 5000); return; }
alert(message);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => { new EnhancedAnalyticsDashboard(); });
</script>
{% endblock %}