mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-07 21:10:46 -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)
512 lines
24 KiB
HTML
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 %}
|
|
|
|
|