mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-08 05:19:48 -05:00
77aec94b86
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)
524 lines
27 KiB
HTML
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 %}
|