Files
TimeTracker/templates/reports/project_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

524 lines
27 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Project 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">{{ _('Project Report') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-chart-bar text-primary"></i> {{ _('Project 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="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-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-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> {{ _('Apply Filters') }}
</button>
<a href="{{ url_for('reports.project_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-success bg-opacity-10 text-success">
<i class="fas fa-sack-dollar"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Amount') }}</div>
<div class="summary-value">{{ currency }} {{ "%.2f"|format(summary.total_billable_amount) }}</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-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>
<!-- Project Comparison Chart -->
{% if projects_data|length > 0 %}
<div class="row mb-4">
<div class="col-12">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-bar me-2"></i>{{ _('Project Hours Comparison') }}
</h5>
<div class="chart-controls">
<button type="button" class="chart-toggle-btn active" data-chart-type="bar">
<i class="fas fa-chart-bar"></i>
</button>
<button type="button" class="chart-toggle-btn" data-chart-type="line">
<i class="fas fa-chart-line"></i>
</button>
</div>
</div>
<canvas id="projectComparisonChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Project 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> {{ _('Project Breakdown') }} ({{ projects_data|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" id="projectTableSearch" placeholder="{{ _('Search projects...') }}">
</div>
</div>
<div class="card-body p-0">
{% if projects_data|length > 0 and selected_project %}
<div class="p-3">
<canvas id="burndownAllChart" height="90"></canvas>
</div>
{% endif %}
{% if projects_data %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="projectsTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Client') }}</th>
<th data-sortable>{{ _('Total Hours') }}</th>
<th data-sortable>{{ _('Billable Hours') }}</th>
<th data-sortable>{{ _('Billable Amount') }}</th>
<th>{{ _('Users') }}</th>
<th class="text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects_data %}
<tr>
<td>
<div>
<strong>{{ project.name }}</strong>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td>
{% if project.client %}
{{ project.client }}
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
<strong>{{ "%.1f"|format(project.total_hours) }}h</strong>
</td>
<td>
{% if project.billable %}
<span class="text-success">{{ "%.1f"|format(project.billable_hours) }}h</span>
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
{% if project.billable and project.billable_amount > 0 %}
<span class="text-success">{{ currency }} {{ "%.2f"|format(project.billable_amount) }}</span>
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-column">
{% for user_total in project.user_totals %}
<small>
{{ user_total.username }}: {{ "%.1f"|format(user_total.hours) }}h
</small>
{% endfor %}
</div>
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view" title="{{ _('View Project') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('reports.project_report') }}?project_id={{ project.id }}&{{ request.query_string.decode() }}"
class="btn btn-sm btn-action btn-action--more" title="{{ _('Filter by Project') }}">
<i class="fas fa-filter"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-chart-bar fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No Data Found') }}</h5>
<p class="text-muted">
{% if request.args.get('start_date') or request.args.get('end_date') or request.args.get('project_id') or request.args.get('user_id') %}
{{ _('Try adjusting your filters or') }}
<a href="{{ url_for('reports.project_report') }}">{{ _('view all projects') }}</a>.
{% else %}
{{ _('No time entries have been recorded yet.') }}
{% endif %}
</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>
{% 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>
document.addEventListener('DOMContentLoaded', async function(){
try {
const projectId = Number(new URLSearchParams(window.location.search).get('project_id'));
if (!projectId) return; // Only draw when a project is selected
const res = await fetch(`/api/projects/${projectId}/burndown`);
const data = await res.json();
const ctx = document.getElementById('burndownAllChart').getContext('2d');
new Chart(ctx, { type: 'line', data: { labels: data.labels, datasets: [
{ label: '{{ _('Actual (cum hrs)') }}', data: data.actual_cumulative, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,.08)', tension:.2 },
{ label: '{{ _('Estimate') }}', data: data.estimated, borderColor: '#9ca3af', borderDash:[6,4], tension:0 }
] }, options: { responsive: true, maintainAspectRatio: false } });
} catch(e) { console.error(e); }
});
// 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 Project Comparison Chart
document.addEventListener('DOMContentLoaded', function() {
{% if projects_data %}
const projectsData = [
{% for project in projects_data %}
{
name: "{{ project.name|safe }}",
total_hours: {{ project.total_hours }},
billable_hours: {{ project.billable_hours }},
billable_amount: {{ project.billable_amount }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (projectsData.length > 0) {
window.ReportsEnhanced.ReportCharts.createProjectComparisonChart('projectComparisonChart', projectsData);
}
{% endif %}
// Table search for projects
const projectSearch = document.getElementById('projectTableSearch');
if (projectSearch) {
projectSearch.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const table = document.getElementById('projectsTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}
{% endif %}
</div>
{% endblock %}