mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
8d4ec0e25f
## 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)
701 lines
36 KiB
HTML
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 %}
|
|
|