diff --git a/Dockerfile b/Dockerfile index d34c7c74..306f8da3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y \ fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* +# Install PostgreSQL 16 client tools (pg_dump/pg_restore) from PGDG to match server 16.x +RUN apt-get update && apt-get install -y gnupg wget lsb-release && \ + sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ + apt-get update && apt-get install -y postgresql-client-16 && \ + rm -rf /var/lib/apt/lists/* + # Set work directory WORKDIR /app diff --git a/app/models/project.py b/app/models/project.py index 41836f97..fe38e010 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -125,7 +125,9 @@ def get_user_totals(self, start_date=None, end_date=None): from .user import User query = db.session.query( + User.id, User.username, + User.full_name, db.func.sum(TimeEntry.duration_seconds).label('total_seconds') ).join(TimeEntry).filter( TimeEntry.project_id == self.id, @@ -138,14 +140,14 @@ def get_user_totals(self, start_date=None, end_date=None): if end_date: query = query.filter(TimeEntry.start_time <= end_date) - results = query.group_by(User.username).all() + results = query.group_by(User.id, User.username, User.full_name).all() return [ { - 'username': username, + 'username': (full_name.strip() if full_name and full_name.strip() else username), 'total_hours': round(total_seconds / 3600, 2) } - for username, total_seconds in results + for _id, username, full_name, total_seconds in results ] def archive(self): diff --git a/app/models/user.py b/app/models/user.py index da4d5e92..7dff1513 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -10,6 +10,7 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) + full_name = db.Column(db.String(200), nullable=True) role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin' created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) @@ -50,6 +51,13 @@ def total_hours(self): TimeEntry.end_time.isnot(None) ).scalar() or 0 return round(total_seconds / 3600, 2) + + @property + def display_name(self): + """Preferred display name: full name if available, else username""" + if self.full_name and self.full_name.strip(): + return self.full_name.strip() + return self.username def get_recent_entries(self, limit=10): """Get recent time entries for this user""" @@ -70,6 +78,8 @@ def to_dict(self): return { 'id': self.id, 'username': self.username, + 'full_name': self.full_name, + 'display_name': self.display_name, 'role': self.role, 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_login': self.last_login.isoformat() if self.last_login else None, diff --git a/app/routes/admin.py b/app/routes/admin.py index c7704136..6972bb72 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file from flask_login import login_required, current_user from app import db from app.models import User, Project, TimeEntry, Settings @@ -8,9 +8,15 @@ from werkzeug.utils import secure_filename import uuid from app.utils.db import safe_commit +from app.utils.backup import create_backup, restore_backup +import threading +import time admin_bp = Blueprint('admin', __name__) +# In-memory restore progress tracking (simple, per-process) +RESTORE_PROGRESS = {} + # Allowed file extensions for logos ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'} @@ -313,15 +319,79 @@ def remove_logo(): return redirect(url_for('admin.settings')) -@admin_bp.route('/admin/backup') +@admin_bp.route('/admin/backup', methods=['GET']) @login_required @admin_required def backup(): - """Create manual backup""" - # This would typically trigger a backup process - # For now, just show a success message - flash('Backup process initiated', 'success') - return redirect(url_for('admin.admin_dashboard')) + """Create manual backup and return the archive for download.""" + try: + archive_path = create_backup(current_app) + if not archive_path or not os.path.exists(archive_path): + flash('Backup failed: archive not created', 'error') + return redirect(url_for('admin.admin_dashboard')) + # Stream file to user + return send_file(archive_path, as_attachment=True) + except Exception as e: + flash(f'Backup failed: {e}', 'error') + return redirect(url_for('admin.admin_dashboard')) + +@admin_bp.route('/admin/restore', methods=['GET', 'POST']) +@login_required +@admin_required +def restore(): + """Restore from an uploaded backup archive.""" + if request.method == 'POST': + if 'backup_file' not in request.files or request.files['backup_file'].filename == '': + flash('No backup file uploaded', 'error') + return redirect(url_for('admin.restore')) + file = request.files['backup_file'] + filename = secure_filename(file.filename) + if not filename.lower().endswith('.zip'): + flash('Invalid file type. Please upload a .zip backup archive.', 'error') + return redirect(url_for('admin.restore')) + # Save temporarily under project backups + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + os.makedirs(backups_dir, exist_ok=True) + temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}") + file.save(temp_path) + + # Initialize progress state + token = uuid.uuid4().hex[:8] + RESTORE_PROGRESS[token] = {'status': 'starting', 'percent': 0, 'message': 'Queued'} + + def progress_cb(label, percent): + RESTORE_PROGRESS[token] = {'status': 'running', 'percent': int(percent), 'message': label} + + # Capture the real Flask app object for use in a background thread + app_obj = current_app._get_current_object() + + def _do_restore(): + try: + RESTORE_PROGRESS[token] = {'status': 'running', 'percent': 5, 'message': 'Starting restore'} + success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb) + RESTORE_PROGRESS[token] = { + 'status': 'done' if success else 'error', + 'percent': 100 if success else RESTORE_PROGRESS[token].get('percent', 0), + 'message': message + } + except Exception as e: + RESTORE_PROGRESS[token] = {'status': 'error', 'percent': RESTORE_PROGRESS[token].get('percent', 0), 'message': str(e)} + finally: + try: + os.remove(temp_path) + except Exception: + pass + + # Run restore in background to keep request responsive + t = threading.Thread(target=_do_restore, daemon=True) + t.start() + + flash('Restore started. You can monitor progress on this page.', 'info') + return redirect(url_for('admin.restore', token=token)) + # GET + token = request.args.get('token') + progress = RESTORE_PROGRESS.get(token) if token else None + return render_template('admin/restore.html', progress=progress, token=token) @admin_bp.route('/admin/system') @login_required diff --git a/app/routes/auth.py b/app/routes/auth.py index b0ead5d1..849816d8 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -93,9 +93,15 @@ def profile(): def edit_profile(): """Edit user profile""" if request.method == 'POST': - # For now, just update last login timestamp - current_user.update_last_login() - flash('Profile updated successfully', 'success') + # Update real name if provided + full_name = request.form.get('full_name', '').strip() + current_user.full_name = full_name or None + try: + db.session.commit() + flash('Profile updated successfully', 'success') + except Exception: + db.session.rollback() + flash('Could not update your profile due to a database error.', 'error') return redirect(url_for('auth.profile')) return render_template('auth/edit_profile.html') diff --git a/app/routes/reports.py b/app/routes/reports.py index 81a29bea..618f14a6 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file from flask_login import login_required, current_user from app import db -from app.models import User, Project, TimeEntry, Settings +from app.models import User, Project, TimeEntry, Settings, Task from datetime import datetime, timedelta import csv import io @@ -111,7 +111,7 @@ def project_report(): if project.hourly_rate: agg['billable_amount'] += hours * float(project.hourly_rate) # per-user totals - username = entry.user.username if entry.user else 'Unknown' + username = entry.user.display_name if entry.user else 'Unknown' agg['user_totals'][username] = agg['user_totals'].get(username, 0.0) + hours # Finalize structures @@ -205,7 +205,7 @@ def user_report(): projects_set.add(entry.project.id) if entry.user: users_set.add(entry.user.id) - username = entry.user.username if entry.user else 'Unknown' + username = entry.user.display_name if entry.user else 'Unknown' if username not in user_totals: user_totals[username] = { 'hours': 0, @@ -291,7 +291,7 @@ def export_csv(): for entry in entries: writer.writerow([ entry.id, - entry.user.username, + entry.user.display_name, entry.project.name, entry.project.client, entry.start_time.isoformat(), @@ -374,3 +374,91 @@ def summary_report(): week_hours=week_hours, month_hours=month_hours, project_stats=project_stats[:10]) # Top 10 projects + + +@reports_bp.route('/reports/tasks') +@login_required +def task_report(): + """Report of finished tasks within a project, including hours spent per task""" + project_id = request.args.get('project_id', type=int) + user_id = request.args.get('user_id', type=int) + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # Filters data + projects = Project.query.order_by(Project.name).all() + users = User.query.filter_by(is_active=True).order_by(User.username).all() + + # Default date range: last 30 days + if not start_date: + start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + if not end_date: + end_date = datetime.utcnow().strftime('%Y-%m-%d') + + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + flash('Invalid date format', 'error') + return render_template('reports/task_report.html', projects=projects, users=users) + + # Base tasks query: finished tasks + tasks_query = Task.query.filter(Task.status == 'done') + + if project_id: + tasks_query = tasks_query.filter(Task.project_id == project_id) + + # Filter by completion window intersects [start_dt, end_dt] + tasks_query = tasks_query.filter(Task.completed_at.isnot(None)) + tasks_query = tasks_query.filter(Task.completed_at >= start_dt, Task.completed_at <= end_dt) + + # Optional: only tasks that have time entries by a specific user + if user_id: + tasks_query = tasks_query.join(TimeEntry, TimeEntry.task_id == Task.id).filter(TimeEntry.user_id == user_id) + + tasks = tasks_query.order_by(Task.completed_at.desc()).all() + + # Compute hours per task (sum of entry durations; respect user/project filters and date range) + task_rows = [] + total_hours = 0.0 + for task in tasks: + te_query = TimeEntry.query.filter( + TimeEntry.task_id == task.id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt + ) + if project_id: + te_query = te_query.filter(TimeEntry.project_id == project_id) + if user_id: + te_query = te_query.filter(TimeEntry.user_id == user_id) + + entries = te_query.all() + hours = sum(e.duration_hours for e in entries) + total_hours += hours + + task_rows.append({ + 'task': task, + 'project': task.project, + 'assignee': task.assigned_user, + 'completed_at': task.completed_at, + 'hours': round(hours, 2), + 'entries_count': len(entries), + }) + + summary = { + 'tasks_count': len(task_rows), + 'total_hours': round(total_hours, 2), + } + + return render_template( + 'reports/task_report.html', + projects=projects, + users=users, + tasks=task_rows, + summary=summary, + start_date=start_date, + end_date=end_date, + selected_project=project_id, + selected_user=user_id, + ) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 1e6e7250..a7f27d55 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -332,22 +332,68 @@ def delete_task(task_id): @tasks_bp.route('/tasks/my-tasks') @login_required def my_tasks(): - """Show current user's tasks""" + """Show current user's tasks with filters and pagination""" + page = request.args.get('page', 1, type=int) status = request.args.get('status', '') - - query = Task.query.filter( - db.or_( - Task.assigned_to == current_user.id, - Task.created_by == current_user.id + priority = request.args.get('priority', '') + project_id = request.args.get('project_id', type=int) + search = request.args.get('search', '').strip() + task_type = request.args.get('task_type', '') # '', 'assigned', 'created' + + query = Task.query + + # Restrict to current user's tasks depending on task_type filter + if task_type == 'assigned': + query = query.filter(Task.assigned_to == current_user.id) + elif task_type == 'created': + query = query.filter(Task.created_by == current_user.id) + else: + query = query.filter( + db.or_( + Task.assigned_to == current_user.id, + Task.created_by == current_user.id + ) ) - ) - + + # Apply filters if status: query = query.filter_by(status=status) - - tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - - return render_template('tasks/my_tasks.html', tasks=tasks, status=status) + + if priority: + query = query.filter_by(priority=priority) + + if project_id: + query = query.filter_by(project_id=project_id) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Task.name.ilike(like), + Task.description.ilike(like) + ) + ) + + tasks = query.order_by( + Task.priority.desc(), + Task.due_date.asc(), + Task.created_at.asc() + ).paginate(page=page, per_page=20, error_out=False) + + # Provide projects for filter dropdown + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + + return render_template( + 'tasks/my_tasks.html', + tasks=tasks.items, + pagination=tasks, + projects=projects, + status=status, + priority=priority, + project_id=project_id, + search=search, + task_type=task_type + ) @tasks_bp.route('/tasks/overdue') @login_required diff --git a/app/static/base.css b/app/static/base.css index 6c1cf473..c1d02c57 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -77,7 +77,8 @@ main { border-radius: var(--border-radius); transition: var(--transition); background: white; - overflow: hidden; + /* Allow dropdown menus within cards to overflow properly */ + overflow: visible; margin-bottom: var(--card-spacing); } @@ -85,7 +86,7 @@ main { margin-bottom: 0; } -.card:hover { +.card.hover-lift:hover { box-shadow: var(--card-shadow-hover); transform: translateY(-2px); } @@ -256,6 +257,16 @@ main { color: var(--text-primary); } +/* Keep outline secondary buttons light when opened/active */ +.btn-outline-secondary:focus, +.btn-outline-secondary:active, +.btn-outline-secondary.dropdown-toggle.show, +.show > .btn-outline-secondary.dropdown-toggle { + background: var(--light-color); + border-color: var(--text-secondary); + color: var(--text-primary); +} + /* Unify small/large sizes */ .btn-sm { padding: 0.4rem 0.65rem; @@ -705,11 +716,55 @@ h6 { font-size: 1rem; } background: #ffffff !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; - z-index: 1055; /* above navbar (1030) */ + z-index: 1060; /* above navbar (1030) and our backdrop */ + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + box-shadow: var(--card-shadow-hover); + margin-top: 0.5rem; + overflow: visible; /* allow soft shadow rounding */ + position: absolute !important; /* ensure above backdrop and positioned by Bootstrap */ + pointer-events: auto; /* capture interactions */ + background-clip: padding-box; /* ensure solid fill to rounded corners */ +} + +/* Ensure dropdowns inside cards stack above adjacent content */ +.card .dropdown, +.mobile-card .dropdown { + position: relative; + z-index: 2000; } .dropdown-item { - background-color: transparent; + background-color: #ffffff !important; /* make items opaque */ } +.dropdown-menu::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #ffffff; /* solid background to avoid transparency */ + border-radius: inherit; + z-index: -1; /* sit behind menu content but within menu stacking */ +} + +/* Solid hover state for items to further avoid transparency feel */ +.dropdown-item:hover, .dropdown-item:focus { + background-color: var(--light-color) !important; +} + +/* Backdrop to block interactions behind open dropdowns */ +/* Removed custom dropdown backdrop; rely on Bootstrap defaults */ + +/* Increase dropdown item touch targets and spacing */ +.dropdown-item { + padding: 0.6rem 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dropdown-item i { width: 1rem; text-align: center; } /* Enhanced Mobile Components */ .mobile-stack { @@ -927,3 +982,52 @@ h6 { font-size: 1rem; } } +/* Shared summary cards used across pages (invoices, reports) */ +.summary-card { + transition: all 0.3s ease; + border-radius: 12px; +} + +.summary-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.summary-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.summary-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.summary-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.empty-state { + padding: 2rem; +} + +.empty-state i { + opacity: 0.5; +} + +@media (max-width: 768px) { + .summary-card { margin-bottom: 1rem; } + .summary-value { font-size: 18px; } +} + diff --git a/app/templates/auth/edit_profile.html b/app/templates/auth/edit_profile.html index 4727ed1e..ed8ec2e9 100644 --- a/app/templates/auth/edit_profile.html +++ b/app/templates/auth/edit_profile.html @@ -17,6 +17,12 @@
| Username | +User | Role | Status | Created | Last Login | Total Hours | -Actions | +Actions | |
|---|---|---|---|---|---|---|---|---|---|
|
- {{ user.username }}
+ {{ user.display_name }}
{% if user.active_timer %}
Timer Running @@ -129,14 +143,14 @@
+
+ |
+ class="btn btn-sm btn-action btn-action--edit" title="Edit">
{% if user.id != current_user.id %}
-
@@ -150,12 +164,14 @@
{% else %}
-
-
{% endif %}
@@ -204,13 +220,31 @@ No Users Found-No users have been created yet. - - Create First User - +
+
+
No users found+Create your first user to get started with administration. + + Create First User + +
new bootstrap.Modal(document.getElementById('deleteUserModal')).show();
}
-// Add loading state to delete user form
+// Add loading state to delete user form and wire table search
document.addEventListener('DOMContentLoaded', function() {
- document.getElementById('deleteUserForm').addEventListener('submit', function(e) {
- const submitBtn = this.querySelector('button[type="submit"]');
- submitBtn.innerHTML = 'Deleting...';
- submitBtn.disabled = true;
- });
+ const deleteUserForm = document.getElementById('deleteUserForm');
+ if (deleteUserForm) {
+ deleteUserForm.addEventListener('submit', function() {
+ const submitBtn = this.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.innerHTML = 'Deleting...';
+ submitBtn.disabled = true;
+ }
+ });
+ }
+
+ const searchInput = document.getElementById('searchInput');
+ const table = document.getElementById('usersTable');
+ if (searchInput && table) {
+ const tbody = table.querySelector('tbody');
+ searchInput.addEventListener('keyup', function() {
+ const query = this.value.toLowerCase();
+ Array.from(tbody.querySelectorAll('tr')).forEach(function(row) {
+ const text = row.textContent.toLowerCase();
+ row.style.display = text.includes(query) ? '' : 'none';
+ });
+ });
+ }
});
{% endblock %}
diff --git a/templates/clients/list.html b/templates/clients/list.html
index baa6fc1b..4b2aa6ca 100644
--- a/templates/clients/list.html
+++ b/templates/clients/list.html
@@ -7,50 +7,19 @@
-
- - Clients -- {% if current_user.is_admin %} - - New Client - - {% endif %} -
-
-
-
@@ -59,17 +28,28 @@ - Filters -+
+
- + + Clients ++ {{ clients|length }} total
-
+
+ {% if current_user.is_admin %}
+
+ New Client
+
+ {% endif %}
-
-
- - Client List ({{ clients|length }}) -+
+
+
-
+
+ All Clients ++
+
+
+
+
+
+
+
+
+
{% if clients %}
-
|