Files
TimeTracker/templates/reports/user_report.html
T
Dries Peeters 77aec94b86 feat: Add project costs tracking and remove license server integration
Major Features:
- Add project costs feature with full CRUD operations
- Implement toast notification system for better user feedback
- Enhance analytics dashboard with improved visualizations
- Add OIDC authentication improvements and debug tools

Improvements:
- Enhance reports with new filtering and export capabilities
- Update command palette with additional shortcuts
- Improve mobile responsiveness across all pages
- Refactor UI components for consistency

Removals:
- Remove license server integration and related dependencies
- Clean up unused license-related templates and utilities

Technical Changes:
- Add new migration 018 for project_costs table
- Update models: Project, Settings, User with new relationships
- Refactor routes: admin, analytics, auth, invoices, projects, reports
- Update static assets: CSS improvements, new JS modules
- Enhance templates: analytics, admin, projects, reports

Documentation:
- Add comprehensive documentation for project costs feature
- Document toast notification system with visual guides
- Update README with new feature descriptions
- Add migration instructions and quick start guides
- Document OIDC improvements and Kanban enhancements

Files Changed:
- Modified: 56 files (core app, models, routes, templates, static assets)
- Deleted: 6 files (license server integration)
- Added: 28 files (new features, documentation, migrations)
2025-10-09 11:50:26 +02:00

512 lines
24 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('User Report') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a></li>
<li class="breadcrumb-item active">{{ _('User Report') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-chart-pie text-primary"></i> {{ _('User Report') }}
</h1>
</div>
<div>
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-primary">
<i class="fas fa-download"></i> {{ _('Export CSV') }}
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm filters-card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> {{ _('Filters') }}
</h5>
</div>
<div class="card-body">
<!-- Date Range Presets -->
<div class="date-presets-container">
<span class="date-presets-label">
<i class="fas fa-calendar-day me-2"></i>{{ _('Quick Date Ranges') }}
</span>
<div id="datePresets"></div>
</div>
<form method="GET" class="row g-3" id="filtersForm">
<div class="col-md-3">
<label for="start_date" class="form-label">{{ _('Start Date') }}</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ request.args.get('start_date', '') }}">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">{{ _('End Date') }}</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ request.args.get('end_date', '') }}">
</div>
<div class="col-md-3">
<label for="user" class="form-label">{{ _('User') }}</label>
<select class="form-select" id="user" name="user_id">
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if request.args.get('user_id')|int == user.id %}selected{% endif %}>
{{ user.display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="project" class="form-label">{{ _('Project') }}</label>
<select class="form-select" id="project" name="project_id">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> {{ _('Apply Filters') }}
</button>
<a href="{{ url_for('reports.user_report') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> {{ _('Clear') }}
</a>
<div class="btn-group ms-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="saveFilterBtn"><i class="fas fa-bookmark me-1"></i>{{ _('Save Filter') }}</button>
<div class="btn-group" role="group">
<button id="filtersDropdown" type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-list me-1"></i>{{ _('Saved Filters') }}
</button>
<ul class="dropdown-menu" id="savedFiltersMenu"></ul>
</div>
</div>
<!-- Export Options -->
<div class="export-options float-end">
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-success btn-sm export-btn">
<i class="fas fa-file-csv"></i> {{ _('CSV') }}
</a>
<button type="button" class="btn btn-outline-primary btn-sm export-btn" onclick="window.print()">
<i class="fas fa-print"></i> {{ _('Print') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.billable_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-users"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Users') }}</div>
<div class="summary-value">{{ summary.users_count }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-project-diagram"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Projects') }}</div>
<div class="summary-value">{{ summary.projects_count }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- User Distribution Chart -->
{% if user_totals|length > 0 %}
<div class="row mb-4">
<div class="col-lg-8">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-bar me-2"></i>{{ _('User Hours Distribution') }}
</h5>
</div>
<canvas id="userHoursChart"></canvas>
</div>
</div>
<div class="col-lg-4">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-pie me-2"></i>{{ _('User Share') }}
</h5>
</div>
<canvas id="userDistributionChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- User Breakdown -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list"></i> {{ _('User Breakdown') }} ({{ user_totals|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" id="userTableSearch" placeholder="{{ _('Search users...') }}">
</div>
</div>
<div class="card-body p-0">
{% if user_totals %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="usersTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('User') }}</th>
<th data-sortable>{{ _('Total Hours') }}</th>
<th data-sortable>{{ _('Billable Hours') }}</th>
<th data-sortable>{{ _('Billable %') }}</th>
</tr>
</thead>
<tbody>
{% for username, totals in user_totals.items() %}
<tr>
<td><strong>{{ username }}</strong></td>
<td>{{ "%.1f"|format(totals.hours) }}h</td>
<td>
{% if totals.billable_hours > 0 %}
<span class="text-success">{{ "%.1f"|format(totals.billable_hours) }}h</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if totals.hours > 0 %}
{% set billable_percentage = (totals.billable_hours / totals.hours * 100) %}
<div class="d-flex align-items-center">
<div class="progress-compact flex-grow-1 me-2" style="width: 100px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ billable_percentage }}%"
aria-valuenow="{{ billable_percentage }}"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="text-muted">{{ "%.0f"|format(billable_percentage) }}%</span>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No Data Found') }}</h5>
<p class="text-muted">{{ _('Try adjusting your filters.') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Time Entries -->
{% if entries %}
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-clock"></i> {{ _('Time Entries') }} ({{ entries|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" data-table="entriesTable" placeholder="{{ _('Search entries...') }}">
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="entriesTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('User') }}</th>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Task') }}</th>
<th data-sortable>{{ _('Date') }}</th>
<th data-sortable>{{ _('Time') }}</th>
<th data-sortable>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Tags') }}</th>
<th data-sortable>{{ _('Billable') }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>
{% if entry.task %}
<a href="{{ url_for('tasks.view_task', task_id=entry.task.id) }}" class="text-decoration-none">
<i class="fas fa-tasks me-1 text-muted"></i>{{ entry.task.name }}
</a>
{% else %}
<span class="text-muted">{{ _('No task') }}</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.notes %}
<span title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.tag_list %}
{% for tag in entry.tag_list %}
<span class="badge bg-light text-dark">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('No') }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='reports-enhanced.js') }}"></script>
<script>
// Saved Filters UI
document.getElementById('saveFilterBtn')?.addEventListener('click', async function(){
const form = document.getElementById('filtersForm');
const params = new URLSearchParams(new FormData(form));
const payload = {};
if (params.get('project_id')) payload.project_id = Number(params.get('project_id'));
if (params.get('user_id')) payload.user_id = Number(params.get('user_id'));
if (params.get('start_date')) payload.start_date = params.get('start_date');
if (params.get('end_date')) payload.end_date = params.get('end_date');
const name = prompt('{{ _('Name this filter') }}');
if (!name) return;
try {
const res = await fetch('/api/saved-filters', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ name, scope:'reports', payload, is_shared:false }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
loadSavedFilters();
} catch(e) { /* ignore */ }
});
async function loadSavedFilters(){
try {
const res = await fetch('/api/saved-filters?scope=reports', { credentials:'same-origin' });
const json = await res.json();
const menu = document.getElementById('savedFiltersMenu');
if (!menu) return;
menu.innerHTML = '';
(json.filters || []).forEach(f => {
const li = document.createElement('li');
const a = document.createElement('a'); a.className='dropdown-item'; a.href='#'; a.textContent=f.name;
a.addEventListener('click', function(){ applySavedFilter(f); });
li.appendChild(a);
menu.appendChild(li);
});
} catch(e) {}
}
function applySavedFilter(f){
const form = document.getElementById('filtersForm');
if (!form || !f || !f.payload) return;
if (f.payload.start_date) form.querySelector('#start_date').value = f.payload.start_date;
if (f.payload.end_date) form.querySelector('#end_date').value = f.payload.end_date;
if (f.payload.project_id) form.querySelector('#project').value = String(f.payload.project_id);
if (f.payload.user_id) form.querySelector('#user').value = String(f.payload.user_id);
form.submit();
}
document.addEventListener('DOMContentLoaded', loadSavedFilters);
// Initialize User Charts
document.addEventListener('DOMContentLoaded', function() {
{% if user_totals %}
const usersData = [
{% for username, totals in user_totals.items() %}
{
name: "{{ username|safe }}",
hours: {{ totals.hours }},
billable_hours: {{ totals.billable_hours }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (usersData.length > 0) {
// Create bar chart for user hours
const ctx1 = document.getElementById('userHoursChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: usersData.map(u => u.name),
datasets: [
{
label: '{{ _("Total Hours") }}',
data: usersData.map(u => u.hours),
backgroundColor: '#3b82f640',
borderColor: '#3b82f6',
borderWidth: 2
},
{
label: '{{ _("Billable Hours") }}',
data: usersData.map(u => u.billable_hours),
backgroundColor: '#10b98140',
borderColor: '#10b981',
borderWidth: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' }
},
scales: {
y: {
beginAtZero: true,
ticks: { callback: function(value) { return value + 'h'; } }
}
}
}
});
}
// Create doughnut chart for user distribution
window.ReportsEnhanced.ReportCharts.createUserDistributionChart('userDistributionChart', usersData);
}
{% endif %}
// Table search for users
const userSearch = document.getElementById('userTableSearch');
if (userSearch) {
userSearch.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const table = document.getElementById('usersTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}