Merge pull request #34 from DRYTRIX/develop

Develop
This commit is contained in:
Dries Peeters
2025-09-03 20:56:15 +02:00
committed by GitHub
36 changed files with 2570 additions and 1021 deletions
+7
View File
@@ -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
+5 -3
View File
@@ -125,7 +125,9 @@ class Project(db.Model):
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 @@ class Project(db.Model):
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):
+10
View File
@@ -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 @@ class User(UserMixin, db.Model):
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 @@ class User(UserMixin, db.Model):
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,
+77 -7
View File
@@ -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 @@ import os
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
+9 -3
View File
@@ -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')
+92 -4
View File
@@ -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,
)
+58 -12
View File
@@ -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
+108 -4
View File
@@ -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; }
}
+6
View File
@@ -17,6 +17,12 @@
<div class="form-text">Usernames cannot be changed.</div>
</div>
<div class="mb-3">
<label class="form-label">Full name</label>
<input type="text" name="full_name" class="form-control" value="{{ current_user.full_name or '' }}" placeholder="Enter your real name">
<div class="form-text">Shown in tasks and reports when provided.</div>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<input type="text" class="form-control" value="{{ current_user.role|capitalize }}" disabled>
+4
View File
@@ -17,6 +17,10 @@
<div class="col-sm-4 text-muted">Username</div>
<div class="col-sm-8"><strong>{{ current_user.username }}</strong></div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted">Full name</div>
<div class="col-sm-8">{{ current_user.full_name or '—' }}</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted">Role</div>
<div class="col-sm-8">
+3 -1
View File
@@ -105,7 +105,7 @@
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 36px; height: 36px;">
<i class="fas fa-user text-primary"></i>
</div>
<span>{{ current_user.username }}</span>
<span>{{ current_user.display_name }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
@@ -235,6 +235,8 @@
nav.classList.remove('scrolled');
}
}, { passive: true });
// Use Bootstrap's default dropdown behavior; no custom backdrop
</script>
{% if current_user.is_authenticated %}
+44 -41
View File
@@ -6,6 +6,9 @@
<!-- Toast Container -->
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3"></div>
<!-- Template meta for JS flags -->
<div id="dashboard-meta" data-has-active-timer="{{ 1 if active_timer else 0 }}" style="display:none;"></div>
<!-- Enhanced Welcome Section -->
<div class="row section-spacing">
<div class="col-12">
@@ -19,7 +22,7 @@
{% else %}
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="me-3" width="32" height="32">
{% endif %}
<h2 class="mb-0">Welcome back, {{ current_user.username }}!</h2>
<h2 class="mb-0">Welcome back, {{ current_user.display_name }}!</h2>
</div>
<p class="text-muted mb-0 fs-5">Track your productivity and manage your time effectively with <strong>DryTrix</strong> TimeTracker</p>
</div>
@@ -398,48 +401,48 @@
{% block extra_js %}
<script>
let timerInterval;
{% if active_timer %}
// Enhanced timer update
function updateTimer() {
fetch('/api/timer/status')
.then(response => response.json())
.then(data => {
if (data.active && data.timer) {
const display = document.getElementById('timer-display');
if (display) {
// Prefer server-provided current duration; fallback to computing from start_time
let totalSeconds = typeof data.timer.current_duration === 'number'
? data.timer.current_duration
: Math.floor((new Date() - new Date(data.timer.start_time)) / 1000);
if (totalSeconds < 0 || Number.isNaN(totalSeconds)) totalSeconds = 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
display.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
const HAS_ACTIVE_TIMER = !!Number(document.getElementById('dashboard-meta')?.getAttribute('data-has-active-timer') || '0');
if (HAS_ACTIVE_TIMER) {
// Enhanced timer update
function updateTimer() {
fetch('/api/timer/status')
.then(response => response.json())
.then(data => {
if (data.active && data.timer) {
const display = document.getElementById('timer-display');
if (display) {
// Prefer server-provided current duration; fallback to computing from start_time
let totalSeconds = typeof data.timer.current_duration === 'number'
? data.timer.current_duration
: Math.floor((new Date() - new Date(data.timer.start_time)) / 1000);
if (totalSeconds < 0 || Number.isNaN(totalSeconds)) totalSeconds = 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
display.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
} else {
// Timer stopped, reload page
clearInterval(timerInterval);
location.reload();
}
} else {
// Timer stopped, reload page
clearInterval(timerInterval);
location.reload();
}
})
.catch(error => {
console.error('Error updating timer:', error);
});
}
// Update timer immediately and then every second
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (timerInterval) {
clearInterval(timerInterval);
})
.catch(error => {
console.error('Error updating timer:', error);
});
}
});
{% endif %}
// Update timer immediately and then every second
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (timerInterval) {
clearInterval(timerInterval);
}
});
}
// Populate tasks when project changes
const projectSelect = document.getElementById('project_id');
+2 -2
View File
@@ -63,7 +63,7 @@
<option value="">Select a project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.form.get('project_id')|int == project.id or request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.name }}
{{ project.name }} ({{ project.client }})
</option>
{% endfor %}
</select>
@@ -126,7 +126,7 @@
<option value="">Unassigned</option>
{% for user in users %}
<option value="{{ user.id }}" {% if request.form.get('assigned_to')|int == user.id %}selected{% endif %}>
{{ user.username }}
{{ user.display_name }}
</option>
{% endfor %}
</select>
+4 -4
View File
@@ -63,7 +63,7 @@
<option value="">Select a project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if task.project_id == project.id %}selected{% endif %}>
{{ project.name }}
{{ project.name }} ({{ project.client }})
</option>
{% endfor %}
</select>
@@ -126,7 +126,7 @@
<option value="">Unassigned</option>
{% for user in users %}
<option value="{{ user.id }}" {% if task.assigned_to == user.id %}selected{% endif %}>
{{ user.username }}
{{ user.display_name }}
</option>
{% endfor %}
</select>
@@ -188,7 +188,7 @@
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height:24px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span>{{ task.assigned_user.username }}</span>
<span>{{ task.assigned_user.display_name }}</span>
</div>
</div>
{% endif %}
@@ -247,7 +247,7 @@
</a>
{% if task.status == 'todo' or task.status == 'in_progress' %}
<a href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<i class="fas fa-play me-2"></i>Start Timer
</a>
{% endif %}
+75 -66
View File
@@ -4,84 +4,93 @@
{% block content %}
<div class="container mt-4">
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card mobile-card">
<div class="card-body py-4">
<div class="row align-items-center">
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
<div class="d-flex align-items-center mb-2">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
<i class="fas fa-tasks text-primary fa-lg"></i>
</div>
<div>
<h2 class="mb-1">Task Management</h2>
<p class="text-muted mb-0">Organize and track your project tasks efficiently</p>
</div>
</div>
</div>
<div class="col-lg-4 col-md-5 text-center text-md-end">
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center justify-content-md-end">
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>New Task
</a>
<div class="dropdown d-inline-block">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-filter me-2"></i>Views
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('tasks.my_tasks') }}">
<i class="fas fa-user me-2"></i>My Tasks
</a></li>
{% if current_user.is_admin %}
<li><a class="dropdown-item" href="{{ url_for('tasks.overdue_tasks') }}">
<i class="fas fa-exclamation-triangle me-2"></i>Overdue
</a></li>
{% endif %}
</ul>
</div>
</div>
</div>
<!-- Header Section (Invoices-style) -->
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-tasks text-primary"></i>
Tasks
</h1>
<span class="badge bg-primary fs-6">{{ tasks|length }} total</span>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> New Task
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<!-- Summary Cards (Invoices-style) -->
<div class="row mb-4">
<div class="col-12">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="card mobile-card bg-primary bg-opacity-10 border-primary border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-primary mb-1">{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}</div>
<small class="text-muted">To Do</small>
<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-list-check"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">To Do</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-warning bg-opacity-10 border-warning border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-warning mb-1">{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}</div>
<small class="text-muted">In Progress</small>
</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-spinner"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">In Progress</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-info bg-opacity-10 border-info border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-info mb-1">{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}</div>
<small class="text-muted">Review</small>
</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-user-check"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Review</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-success bg-opacity-10 border-success border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-success mb-1">{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}</div>
<small class="text-muted">Completed</small>
</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-check-circle"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Completed</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}</div>
</div>
</div>
</div>
@@ -144,7 +153,7 @@
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>
{{ user.username }}
{{ user.display_name }}
</option>
{% endfor %}
</select>
@@ -194,7 +203,7 @@
</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}">
<li><a class="dropdown-item" href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}">
<i class="fas fa-play me-2"></i>Start Timer
</a></li>
</ul>
@@ -231,7 +240,7 @@
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span class="text-muted small">{{ task.assigned_user.username }}</span>
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
</div>
{% endif %}
@@ -291,7 +300,7 @@
<i class="fas fa-eye me-2"></i>View Details
</a>
{% if task.status == 'todo' or task.status == 'in_progress' %}
<a href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<i class="fas fa-play me-2"></i>Start Timer
</a>
{% endif %}
+83 -52
View File
@@ -4,72 +4,103 @@
{% block content %}
<div class="container mt-4">
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card mobile-card">
<div class="card-body py-4">
<div class="row align-items-center">
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
<div class="d-flex align-items-center mb-2">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
<i class="fas fa-user text-info fa-lg"></i>
</div>
<div>
<h2 class="mb-1">My Tasks</h2>
<p class="text-muted mb-0">Tasks assigned to you and tasks you've created</p>
</div>
</div>
</div>
<div class="col-lg-4 col-md-5 text-center text-md-end">
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center justify-content-md-end">
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>New Task
</a>
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
<i class="fas fa-list me-2"></i>All Tasks
</a>
</div>
<!-- Header Section (Invoices-style) -->
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-user text-primary"></i>
My Tasks
</h1>
<span class="badge bg-primary fs-6">{{ tasks|length }} total</span>
</div>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-filter me-1"></i> Filter
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('tasks.list_tasks') }}">All Tasks</a></li>
<li><a class="dropdown-item" href="{{ url_for('tasks.my_tasks', task_type='assigned') }}">Assigned to Me</a></li>
<li><a class="dropdown-item" href="{{ url_for('tasks.my_tasks', task_type='created') }}">Created by Me</a></li>
</ul>
</div>
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> New Task
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<!-- Summary Cards (Invoices-style) -->
<div class="row mb-4">
<div class="col-12">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="card mobile-card bg-primary bg-opacity-10 border-primary border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-primary mb-1">{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}</div>
<small class="text-muted">To Do</small>
<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-list-check"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">To Do</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-warning bg-opacity-10 border-warning border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-warning mb-1">{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}</div>
<small class="text-muted">In Progress</small>
</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-spinner"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">In Progress</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-info bg-opacity-10 border-info border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-info mb-1">{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}</div>
<small class="text-muted">Review</small>
</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-user-check"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Review</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card mobile-card bg-success bg-opacity-10 border-success border-opacity-25">
<div class="card-body text-center py-3">
<div class="h4 text-success mb-1">{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}</div>
<small class="text-muted">Completed</small>
</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-check-circle"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Completed</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}</div>
</div>
</div>
</div>
@@ -179,7 +210,7 @@
</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}">
<li><a class="dropdown-item" href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}">
<i class="fas fa-play me-2"></i>Start Timer
</a></li>
</ul>
@@ -216,7 +247,7 @@
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span class="text-muted small">{{ task.assigned_user.username }}</span>
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
</div>
{% endif %}
@@ -290,7 +321,7 @@
<i class="fas fa-eye me-2"></i>View Details
</a>
{% if task.status == 'todo' or task.status == 'in_progress' %}
<a href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<i class="fas fa-play me-2"></i>Start Timer
</a>
{% endif %}
+2 -2
View File
@@ -55,7 +55,7 @@
<br><small class="text-muted">
<i class="fas fa-user"></i>
{% if task.assigned_user %}
{{ task.assigned_user.username }}
{{ task.assigned_user.display_name }}
{% else %}
Unassigned
{% endif %}
@@ -97,7 +97,7 @@
<i class="fas fa-edit"></i> Edit
</a>
{% endif %}
<a href="{{ url_for('timer.start_timer', project_id=task.project.id, task_id=task.id) }}"
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project.id, task_id=task.id) }}"
class="btn btn-outline-success">
<i class="fas fa-play"></i> Timer
</a>
+5 -5
View File
@@ -50,7 +50,7 @@
<div class="col-lg-4 col-md-5">
<div class="d-flex flex-column gap-2">
{% if task.status == 'todo' or task.status == 'in_progress' %}
<a href="{{ url_for('timer.start_timer', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success">
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success">
<i class="fas fa-play me-2"></i>Start Timer
</a>
{% endif %}
@@ -178,7 +178,7 @@
</tr>
</thead>
<tbody>
{% for entry in task.time_entries.order_by(desc('start_time')).limit(5).all() %}
{% for entry in time_entries[:5] %}
<tr>
<td>{{ entry.start_time.strftime('%b %d, %Y') }}</td>
<td>
@@ -189,7 +189,7 @@
{% endif %}
</td>
<td>{{ entry.notes[:50] if entry.notes else '-' }}</td>
<td>{{ entry.user.username if entry.user else '-' }}</td>
<td>{{ entry.user.display_name if entry.user else '-' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -236,7 +236,7 @@
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span>{{ task.assigned_user.username }}</span>
<span>{{ task.assigned_user.display_name }}</span>
</div>
</div>
{% endif %}
@@ -247,7 +247,7 @@
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-user-plus text-secondary fa-xs"></i>
</div>
<span>{{ task.creator.username }}</span>
<span>{{ task.creator.display_name }}</span>
</div>
</div>
+330
View File
@@ -0,0 +1,330 @@
import os
import io
import json
import shutil
import tempfile
import subprocess
from datetime import datetime
from zipfile import ZipFile, ZIP_DEFLATED
from urllib.parse import urlparse
def _get_backup_root_dir(app):
"""Compute the absolute backups directory path (project_root/backups)."""
project_root = os.path.abspath(os.path.join(app.root_path, '..'))
backups_dir = os.path.join(project_root, 'backups')
os.makedirs(backups_dir, exist_ok=True)
return backups_dir
def _now_timestamp():
"""Return a human-readable local timestamp for file names."""
# Respect user's preference to use local time across the project
# rather than UTC for user-facing timestamps.
return datetime.now().strftime('%Y%m%d_%H%M%S')
def _detect_db_type_and_path(app):
"""Detect database type and return a tuple (type, uri, sqlite_path).
type: 'sqlite' or 'postgresql'
uri: full SQLAlchemy database URI
sqlite_path: file path if sqlite, otherwise None
"""
uri = app.config.get('SQLALCHEMY_DATABASE_URI', '') or ''
if isinstance(uri, str) and uri.startswith('sqlite:///'):
return 'sqlite', uri, uri.replace('sqlite:///', '')
if isinstance(uri, str) and (uri.startswith('postgresql') or uri.startswith('postgres')):
return 'postgresql', uri, None
# Default/fallback
return 'unknown', uri, None
def _get_alembic_revision(db_session):
"""Return current alembic revision string or None if unavailable."""
try:
from sqlalchemy import text
result = db_session.execute(text('SELECT version_num FROM alembic_version'))
row = result.first()
return row[0] if row else None
except Exception:
return None
def _write_manifest(zf, manifest: dict):
data = json.dumps(manifest, indent=2, sort_keys=True).encode('utf-8')
zf.writestr('manifest.json', data)
def _add_directory_to_zip(zf, source_dir: str, arc_prefix: str):
if not source_dir or not os.path.isdir(source_dir):
return
for root, _, files in os.walk(source_dir):
for file_name in files:
abs_path = os.path.join(root, file_name)
rel_path = os.path.relpath(abs_path, start=source_dir)
zf.write(abs_path, os.path.join(arc_prefix, rel_path))
def create_backup(app) -> str:
"""Create a comprehensive backup archive and return its absolute path.
Contents:
- manifest.json (metadata: created_at, db_type, alembic_revision)
- db.sqlite (if sqlite) OR db.dump (if postgresql, custom format)
- settings.json (serialized Settings row for quick inspection)
- uploads/ (logos and other uploaded assets if present)
"""
# Late imports to avoid circular dependencies
from app import db
from app.models.settings import Settings
backups_dir = _get_backup_root_dir(app)
timestamp = _now_timestamp()
archive_name = f"timetracker_backup_{timestamp}.zip"
archive_path = os.path.join(backups_dir, archive_name)
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
# Prepare temporary directory for DB dumps if needed
tmp_dir = tempfile.mkdtemp(prefix='tt_backup_')
tmp_db_artifact = None
try:
# Create DB artifact
if db_type == 'sqlite' and sqlite_path and os.path.exists(sqlite_path):
tmp_db_artifact = os.path.join(tmp_dir, 'db.sqlite')
shutil.copy2(sqlite_path, tmp_db_artifact)
elif db_type == 'postgresql':
# Use parsed connection parameters (avoid SQLAlchemy driver suffix in URI)
database_url = os.getenv('DATABASE_URL', db_uri)
parsed = urlparse(database_url) if database_url else None
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
tmp_db_artifact = os.path.join(tmp_dir, 'db.dump')
pg_dump_cmd = [
'pg_dump',
'--format=custom',
'-h', host,
'-p', str(port),
'-U', user,
'-d', dbname,
f'--file={tmp_db_artifact}',
]
env = os.environ.copy()
if password:
env['PGPASSWORD'] = str(password)
try:
completed = subprocess.run(pg_dump_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
except FileNotFoundError:
raise RuntimeError('pg_dump not found. Please ensure PostgreSQL client tools are installed.')
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
raise RuntimeError(f'pg_dump failed: {stderr.strip() or e}')
else:
# Best effort: we continue without DB artifact
tmp_db_artifact = None
# Gather metadata
alembic_rev = _get_alembic_revision(db.session)
manifest = {
'created_at': datetime.now().isoformat(timespec='seconds'),
'db_type': db_type,
'alembic_revision': alembic_rev,
'app_version': None,
}
# Serialize settings for convenience (DB backup still authoritative)
settings_obj = Settings.get_settings()
settings_json = json.dumps(settings_obj.to_dict(), indent=2, sort_keys=True)
# Write the zip
with ZipFile(archive_path, mode='w', compression=ZIP_DEFLATED) as zf:
_write_manifest(zf, manifest)
if tmp_db_artifact and os.path.exists(tmp_db_artifact):
arc_name = 'db.sqlite' if db_type == 'sqlite' else 'db.dump'
zf.write(tmp_db_artifact, arc_name)
zf.writestr('settings.json', settings_json.encode('utf-8'))
# Include uploads (e.g., logos)
uploads_root = os.path.join(app.root_path, 'static', 'uploads')
_add_directory_to_zip(zf, uploads_root, 'uploads')
return archive_path
finally:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
def restore_backup(app, archive_path: str, progress_callback=None) -> tuple[bool, str]:
"""Restore a backup archive.
Steps:
- Extract archive to temp dir
- Restore DB depending on type
- Copy uploads back
- Run migrations to head to ensure compatibility with newer code
Returns: (success, message)
"""
from app import db
from time import sleep
if not archive_path or not os.path.exists(archive_path):
return False, f"Backup archive not found: {archive_path}"
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
tmp_dir = tempfile.mkdtemp(prefix='tt_restore_')
def _progress(label: str, percent: int):
try:
if callable(progress_callback):
progress_callback(label, percent)
except Exception:
pass
try:
# Extract archive
with ZipFile(archive_path, mode='r') as zf:
zf.extractall(tmp_dir)
_progress('Archive extracted', 10)
# Read manifest (optional)
manifest_path = os.path.join(tmp_dir, 'manifest.json')
if os.path.exists(manifest_path):
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
_ = json.load(f)
except Exception:
pass
# Proactively close any open DB connections before modifying files
try:
with app.app_context():
db.session.remove()
db.engine.dispose()
except Exception:
pass
# Restore DB
if db_type == 'sqlite':
src_sqlite = os.path.join(tmp_dir, 'db.sqlite')
if not os.path.exists(src_sqlite):
return False, 'Backup does not contain db.sqlite for SQLite restore'
if not sqlite_path:
return False, 'Current configuration is not SQLite or path not found'
# Ensure destination directory exists
dest_dir = os.path.dirname(sqlite_path)
if dest_dir and not os.path.exists(dest_dir):
os.makedirs(dest_dir, exist_ok=True)
# Safety copy of current DB if exists
if os.path.exists(sqlite_path):
safety_copy = sqlite_path + f'.bak_{_now_timestamp()}'
shutil.copy2(sqlite_path, safety_copy)
# Replace DB file
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
# Retry a few times in case the file is briefly locked
last_err = None
for _ in range(3):
try:
shutil.copy2(src_sqlite, sqlite_path)
last_err = None
break
except Exception as e:
last_err = e
sleep(0.2)
if last_err:
return False, f'Failed to write SQLite database file: {last_err}'
_progress('SQLite database restored', 60)
elif db_type == 'postgresql':
src_dump = os.path.join(tmp_dir, 'db.dump')
if not os.path.exists(src_dump):
return False, 'Backup does not contain db.dump for PostgreSQL restore'
database_url = os.getenv('DATABASE_URL', db_uri)
parsed = urlparse(database_url) if database_url else None
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
pg_restore_cmd = [
'pg_restore',
'--clean',
'--if-exists',
'--no-owner',
'-h', host,
'-p', str(port),
'-U', user,
'-d', dbname,
src_dump,
]
env = os.environ.copy()
if password:
env['PGPASSWORD'] = str(password)
try:
_progress('Restoring PostgreSQL database', 20)
subprocess.run(pg_restore_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
except FileNotFoundError:
return False, 'pg_restore not found. Please install PostgreSQL client tools.'
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
return False, f'pg_restore failed: {stderr.strip() or e}'
_progress('PostgreSQL database restored', 60)
else:
return False, 'Unsupported or unknown database type for restore'
# Restore uploads
extracted_uploads = os.path.join(tmp_dir, 'uploads')
if os.path.isdir(extracted_uploads):
target_uploads = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(target_uploads, exist_ok=True)
# Merge copy
for root, _, files in os.walk(extracted_uploads):
rel = os.path.relpath(root, extracted_uploads)
target_dir = os.path.join(target_uploads, rel) if rel != '.' else target_uploads
os.makedirs(target_dir, exist_ok=True)
for fn in files:
shutil.copy2(os.path.join(root, fn), os.path.join(target_dir, fn))
_progress('Uploads restored', 80)
# Run migrations to ensure compatibility with current code
try:
from flask_migrate import upgrade as alembic_upgrade
with app.app_context():
_progress('Running migrations', 90)
alembic_upgrade()
except Exception as e:
# If migrations fail, report failure to caller for visibility
return False, f'Restore completed but migration failed: {e}'
# Dispose connections once more after restore/migrate to ensure clean state
try:
with app.app_context():
db.session.remove()
db.engine.dispose()
except Exception:
pass
_progress('Restore completed successfully', 100)
return True, 'Restore completed successfully'
finally:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
+31
View File
@@ -5,6 +5,7 @@ from app import db
from app.models import User, Project, TimeEntry, Settings, Client
from datetime import datetime, timedelta
import shutil
from app.utils.backup import create_backup, restore_backup
def register_cli_commands(app):
"""Register CLI commands for the application"""
@@ -94,6 +95,36 @@ def register_cli_commands(app):
except Exception as e:
click.echo(f"Warning: Could not clean up old backups: {e}")
@app.cli.command()
@with_appcontext
def backup_create():
"""Create a full backup archive (DB, settings, uploads)."""
try:
archive_path = create_backup(click.get_current_context().obj or app)
if archive_path:
click.echo(f"Backup created: {archive_path}")
else:
click.echo("Backup failed: no archive path returned")
except Exception as e:
click.echo(f"Backup failed: {e}")
@app.cli.command()
@with_appcontext
@click.argument('archive_path')
def backup_restore(archive_path):
"""Restore from a backup archive and run migrations."""
if not archive_path:
click.echo('Usage: flask backup_restore <path_to_backup_zip>')
return
try:
success, message = restore_backup(click.get_current_context().obj or app, archive_path)
click.echo(message)
if not success:
raise SystemExit(1)
except Exception as e:
click.echo(f"Restore failed: {e}")
raise SystemExit(1)
@app.cli.command()
@with_appcontext
def migrate_to_flask_migrate():
@@ -0,0 +1,25 @@
"""Add full_name to users
Revision ID: 002
Revises: 001
Create Date: 2025-01-15 11:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users', sa.Column('full_name', sa.String(length=200), nullable=True))
def downgrade():
op.drop_column('users', 'full_name')
+223 -242
View File
@@ -4,252 +4,233 @@
{% 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">
<h1 class="h3 mb-0">
<i class="fas fa-cogs text-primary"></i> Admin Dashboard
</h1>
<div>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-info">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
<i class="fas fa-download"></i> Backup
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card stats-card hover-lift mb-4">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="mb-3 mb-md-0">
<h1 class="h3 mb-1"><i class="fas fa-cogs me-2"></i>Admin Dashboard</h1>
<p class="mb-0">Manage users, system settings, and core operations at a glance.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-download"></i> Create Backup
</a>
<a href="{{ url_for('admin.restore') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-undo-alt"></i> Restore
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
<!-- System Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-users fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ stats.total_users }}</h4>
<p class="text-muted mb-0">Total Users</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ stats.total_projects }}</h4>
<p class="text-muted mb-0">Total Projects</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-clock fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ stats.total_entries }}</h4>
<p class="text-muted mb-0">Time Entries</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ "%.1f"|format(stats.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
</div>
</div>
</div>
</div>
<!-- System 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 hover-lift">
<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-users"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Users</div>
<div class="summary-value">{{ stats.total_users }}</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 hover-lift">
<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-project-diagram"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Projects</div>
<div class="summary-value">{{ stats.total_projects }}</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 hover-lift">
<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-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Time Entries</div>
<div class="summary-value">{{ stats.total_entries }}</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 hover-lift">
<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-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Hours</div>
<div class="summary-value">{{ "%.1f"|format(stats.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-user-cog"></i> User Management
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-primary">
<i class="fas fa-users"></i> Manage Users
</a>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-outline-success">
<i class="fas fa-user-plus"></i> Create New User
</a>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-user-cog me-2"></i>User Management
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-soft-primary">
<i class="fas fa-users"></i> Manage Users
</a>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-soft-success">
<i class="fas fa-user-plus"></i> Create New User
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cog"></i> System Settings
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-secondary">
<i class="fas fa-sliders-h"></i> Configure Settings
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
<i class="fas fa-download"></i> Create Backup
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cog me-2"></i>System Settings
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.settings') }}" class="btn btn-soft-secondary">
<i class="fas fa-sliders-h"></i> Configure Settings
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-soft-primary">
<i class="fas fa-download"></i> Create Backup
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history"></i> Recent Activity
</h5>
</div>
<div class="card-body">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>User</th>
<th>Project</th>
<th>Date</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for entry in recent_entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.end_time %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning">Running</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-clock fa-2x text-muted mb-3"></i>
<h5 class="text-muted">No Recent Activity</h5>
<p class="text-muted">No time entries have been recorded recently.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-history me-2"></i>Recent Activity
</h6>
</div>
<div class="card-body p-0">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
<th>Date</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for entry in recent_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>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.end_time %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning">Running</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-clock fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No recent activity</h5>
<p class="text-muted mb-0">No time entries have been recorded recently.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie"></i> System Overview
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<h6>Active Users</h6>
<div class="progress mb-2">
<div class="progress-bar" role="progressbar"
style="width: {{ (stats.active_users / stats.total_users * 100) if stats.total_users > 0 else 0 }}%">
{{ stats.active_users }}/{{ stats.total_users }}
</div>
</div>
</div>
<div class="mb-3">
<h6>Active Projects</h6>
<div class="progress mb-2">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ (stats.active_projects / stats.total_projects * 100) if stats.total_projects > 0 else 0 }}%">
{{ stats.active_projects }}/{{ stats.total_projects }}
</div>
</div>
</div>
<div class="mb-3">
<h6>Billable Hours</h6>
<div class="progress mb-2">
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{ (stats.billable_hours / stats.total_hours * 100) if stats.total_hours > 0 else 0 }}%">
{{ "%.1f"|format(stats.billable_hours) }}h/{{ "%.1f"|format(stats.total_hours) }}h
</div>
</div>
</div>
<div class="mt-4">
<h6>System Health</h6>
<div class="d-flex justify-content-between align-items-center">
<span>Database</span>
<span class="badge bg-success">Healthy</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Backup Status</span>
{% if stats.last_backup %}
<span class="badge bg-success">{{ stats.last_backup.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="badge bg-warning">No Backup</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>License Server</span>
<a href="{{ url_for('admin.license_status') }}" class="badge bg-info text-decoration-none">View Status</a>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-tools"></i> Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-plus"></i> New Project
</a>
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-info">
<i class="fas fa-chart-line"></i> View Reports
</a>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-sm btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 mt-3">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-tools me-2"></i>Quick Actions
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-outline-primary">
<i class="fas fa-plus"></i> New Project
</a>
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-info">
<i class="fas fa-chart-line"></i> View Reports
</a>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-secondary">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends 'base.html' %}
{% block title %}Restore Backup{% 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 class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-undo-alt text-danger"></i>
Restore Backup
</h1>
<span class="badge bg-danger fs-6">Danger Operation</span>
</div>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-file-archive me-2"></i>Upload Backup Archive (.zip)
</h6>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Restoring a backup will overwrite your current database and files. Ensure you have a recent backup before proceeding.
</div>
{% if progress %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Status:</strong>
<span class="badge {{ 'bg-success' if progress.status == 'done' else ('bg-danger' if progress.status == 'error' else 'bg-info') }}">
{{ progress.status|title }}
</span>
</div>
<div class="progress progress-thin mb-2">
<div class="progress-bar" role="progressbar" style="width: {{ progress.percent }}%">
{{ progress.percent }}%
</div>
</div>
<div class="text-muted small">{{ progress.message }}</div>
</div>
<script>
// Auto-refresh progress every 2s while running
(function(){
const status = "{{ progress.status }}";
if (status === 'running') {
setTimeout(function(){ window.location.href = "{{ url_for('admin.restore', token=token) }}"; }, 2000);
}
})();
</script>
{% endif %}
<form action="{{ url_for('admin.restore') }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="backup_file" class="form-label">Backup Archive (.zip)</label>
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Select a .zip archive previously created via the Backup action.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="fas fa-undo-alt me-1"></i> Restore
</button>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-shield-alt me-2"></i>Safety Tips
</h6>
</div>
<div class="card-body">
<ul class="text-muted mb-0">
<li>Verify the backup archive integrity before restoring.</li>
<li>Ensure no active writes are occurring during restore.</li>
<li>Keep a copy of the current data in case you need to roll back.</li>
<li>After restore, review settings and re-run migrations if required.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+64 -30
View File
@@ -7,16 +7,20 @@
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<div class="d-flex align-items-start flex-column flex-md-row">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('admin.admin_dashboard') }}">Admin</a></li>
<li class="breadcrumb-item active">Users</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-users text-primary"></i> User Management
</h1>
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-users text-primary"></i>
User Management
</h1>
<span class="badge bg-primary fs-6">{{ users|length }} total</span>
</div>
</div>
<div>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
@@ -70,25 +74,35 @@
<!-- Users List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-list"></i> Users ({{ users|length }})
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Users
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 280px;">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput" placeholder="Search users...">
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover">
<table class="table table-hover mb-0" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Total Hours</th>
<th>Actions</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@@ -96,7 +110,7 @@
<tr>
<td>
<div>
<strong>{{ user.username }}</strong>
<strong>{{ user.display_name }}</strong>
{% if user.active_timer %}
<br><small class="text-warning">
<i class="fas fa-clock"></i> Timer Running
@@ -129,14 +143,14 @@
<td>
<strong>{{ "%.1f"|format(user.total_hours) }}h</strong>
</td>
<td>
<div class="btn-group" role="group">
<td class="text-center actions-cell">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}"
class="btn btn-sm btn-outline-primary" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% if user.id != current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="Delete"
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
<i class="fas fa-trash"></i>
</button>
@@ -150,12 +164,14 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Users Found</h4>
<p class="text-muted">No users have been created yet.</p>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus"></i> Create First User
</a>
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No users found</h5>
<p class="text-muted mb-4">Create your first user to get started with administration.</p>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i> Create First User
</a>
</div>
</div>
{% endif %}
</div>
@@ -204,13 +220,31 @@ function showDeleteUserModal(userId, username) {
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 = '<div class="loading-spinner me-2"></div>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 = '<div class="loading-spinner me-2"></div>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';
});
});
}
});
</script>
{% endblock %}
+88 -58
View File
@@ -7,50 +7,19 @@
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-building text-primary"></i> Clients
</h1>
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> New Client
</a>
{% endif %}
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-filter me-2 text-primary"></i>Filters
</h6>
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-building text-primary"></i>
Clients
</h1>
<span class="badge bg-primary fs-6">{{ clients|length }} total</span>
</div>
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All Statuses</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if request.args.get('status') == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
<div class="col-md-6">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
value="{{ request.args.get('search', '') }}"
placeholder="Search by name, description, contact person, or email">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search"></i> Search
</button>
</div>
</form>
<div class="d-flex gap-2">
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> New Client
</a>
{% endif %}
</div>
</div>
</div>
@@ -59,17 +28,28 @@
<!-- Client List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-list me-1"></i> Client List ({{ clients|length }})
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Clients
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 250px;">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput"
placeholder="Search clients...">
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if clients %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0" id="clientsTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Contact Person</th>
@@ -83,7 +63,7 @@
</thead>
<tbody>
{% for client in clients %}
<tr>
<tr class="client-row" data-status="{{ client.status }}">
<td>
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-decoration-none">
<strong>{{ client.name }}</strong>
@@ -109,17 +89,18 @@
{% endif %}
</td>
<td>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle rounded-pill">{{ client.total_projects }}</span>
<span class="badge badge-soft-primary badge-pill">{{ client.total_projects }}</span>
{% if client.active_projects > 0 %}
<br><small class="text-muted">{{ client.active_projects }} active</small>
{% endif %}
</td>
<td>
{% if client.status == 'active' %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle">Active</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">Inactive</span>
{% endif %}
{% set status_map = {
'active': {'bg': 'bg-success', 'label': 'Active'},
'inactive': {'bg': 'bg-secondary', 'label': 'Inactive'}
} %}
{% set sc = status_map.get(client.status, status_map['inactive']) %}
<span class="status-badge {{ sc.bg }} text-white">{{ sc.label }}</span>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@@ -180,4 +161,53 @@
</div>
</div>
</div>
<style>
/* Align clients list visuals with invoices list */
.status-badge { padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; }
.badge-pill { border-radius: 9999px; }
.badge-soft-primary { background: rgba(59,130,246,0.12); color: var(--primary-color); border: 1px solid rgba(59,130,246,0.25); }
.empty-state { padding: 2rem; }
.btn-group .btn { border-radius: 6px !important; }
.btn-group .btn:not(:last-child) { margin-right: 2px; }
@media (max-width: 768px) {
.table-responsive { font-size: 14px; }
.btn-group .btn { padding: 0.375rem 0.5rem; font-size: 12px; }
.status-badge { font-size: 10px; padding: 4px 8px; }
}
</style>
{% block extra_js %}
<script>
// Simple search (vanilla JS)
(function() {
const searchInput = document.getElementById('searchInput');
const table = document.getElementById('clientsTable');
if (!table) return;
const rows = Array.from(table.querySelectorAll('tbody tr'));
function normalize(text) { return (text || '').toLowerCase(); }
function matchesSearch(row, term) {
if (!term) return true;
const cellsText = Array.from(row.querySelectorAll('td')).map(td => td.innerText).join(' ');
return normalize(cellsText).includes(term);
}
function applyFilters() {
const term = normalize(searchInput ? searchInput.value : '');
rows.forEach(row => {
const show = matchesSearch(row, term);
row.style.display = show ? '' : 'none';
});
}
if (searchInput) {
searchInput.addEventListener('input', applyFilters);
}
// Initial
applyFilters();
})();
</script>
{% endblock %}
{% endblock %}
+173 -75
View File
@@ -21,80 +21,157 @@
</a>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-building text-primary"></i>
{{ client.name }}
</h1>
{% set status_map = {
'active': {'bg': 'bg-success', 'label': 'Active'},
'inactive': {'bg': 'bg-secondary', 'label': 'Inactive'}
} %}
{% set sc = status_map.get(client.status, status_map['inactive']) %}
<span class="status-badge-large {{ sc.bg }} text-white">{{ sc.label }}</span>
</div>
<div class="btn-group" role="group">
{% if current_user.is_admin %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="btn btn-secondary">
<i class="fas fa-edit me-1"></i> Edit
</a>
{% endif %}
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back
</a>
</div>
</div>
</div>
</div>
<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 d-flex align-items-center">
<div class="summary-icon bg-primary bg-opacity-10 text-primary me-3">
<i class="fas fa-project-diagram"></i>
</div>
<div>
<div class="summary-label">Total Projects</div>
<div class="summary-value">{{ client.total_projects }}</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 d-flex align-items-center">
<div class="summary-icon bg-success bg-opacity-10 text-success me-3">
<i class="fas fa-toggle-on"></i>
</div>
<div>
<div class="summary-label">Active Projects</div>
<div class="summary-value">{{ client.active_projects }}</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 d-flex align-items-center">
<div class="summary-icon bg-info bg-opacity-10 text-info me-3">
<i class="fas fa-clock"></i>
</div>
<div>
<div class="summary-label">Total Hours</div>
<div class="summary-value">{{ "%.1f"|format(client.total_hours) }}</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 d-flex align-items-center">
<div class="summary-icon bg-warning bg-opacity-10 text-warning me-3">
<i class="fas fa-sack-dollar"></i>
</div>
<div>
<div class="summary-label">Est. Total Cost</div>
<div class="summary-value">{{ "%.2f"|format(client.estimated_total_cost) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Client Information -->
<div class="col-lg-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Client Information
</h5>
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Client Information
</h6>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Status:</strong>
{% if client.status == 'active' %}
<span class="badge bg-success ms-2">Active</span>
{% else %}
<span class="badge bg-secondary ms-2">Inactive</span>
{% endif %}
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value"><span class="status-badge-large {{ sc.bg }} text-white">{{ sc.label }}</span></span>
</div>
</div>
{% if client.description %}
<div class="mb-3">
<strong>Description:</strong>
<p class="mb-0 mt-1">{{ client.description }}</p>
<div class="section-title text-primary mb-2">Description</div>
<div class="content-box">{{ client.description }}</div>
</div>
{% endif %}
{% if client.contact_person %}
<div class="mb-3">
<strong>Contact Person:</strong>
<p class="mb-0 mt-1">{{ client.contact_person }}</p>
<div class="detail-row">
<span class="detail-label">Contact Person</span>
<span class="detail-value">{{ client.contact_person }}</span>
</div>
</div>
{% endif %}
{% if client.email %}
<div class="mb-3">
<strong>Email:</strong>
<p class="mb-0 mt-1">
<a href="mailto:{{ client.email }}">{{ client.email }}</a>
</p>
<div class="detail-row">
<span class="detail-label">Email</span>
<span class="detail-value"><a href="mailto:{{ client.email }}">{{ client.email }}</a></span>
</div>
</div>
{% endif %}
{% if client.phone %}
<div class="mb-3">
<strong>Phone:</strong>
<p class="mb-0 mt-1">{{ client.phone }}</p>
<div class="detail-row">
<span class="detail-label">Phone</span>
<span class="detail-value">{{ client.phone }}</span>
</div>
</div>
{% endif %}
{% if client.address %}
<div class="mb-3">
<strong>Address:</strong>
<p class="mb-0 mt-1">{{ client.address }}</p>
<div class="section-title text-primary mb-2">Address</div>
<div class="content-box">{{ client.address }}</div>
</div>
{% endif %}
{% if client.default_hourly_rate %}
<div class="mb-3">
<strong>Default Hourly Rate:</strong>
<p class="mb-0 mt-1">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}</p>
<div class="detail-row">
<span class="detail-label">Default Hourly Rate</span>
<span class="detail-value">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}</span>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Client Statistics -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> Statistics
</h5>
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-bar me-2"></i>Statistics
</h6>
</div>
<div class="card-body">
<div class="row text-center">
@@ -123,33 +200,31 @@
<!-- Client Actions -->
{% if current_user.is_admin %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cogs"></i> Actions
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cog me-2"></i>Status & Actions
</h6>
</div>
<div class="card-body">
{% if client.status == 'active' %}
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}"
class="mb-2" onsubmit="return confirm('Are you sure you want to archive this client?')">
<button type="submit" class="btn btn-warning w-100">
<i class="fas fa-archive"></i> Archive Client
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}" class="mb-2" onsubmit="return confirm('Are you sure you want to archive this client?')">
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="fas fa-archive me-2"></i>Archive Client
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('clients.activate_client', client_id=client.id) }}" class="mb-2">
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-check"></i> Activate Client
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-check me-2"></i>Activate Client
</button>
</form>
{% endif %}
{% if client.total_projects == 0 %}
<form method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}"
onsubmit="return confirm('Are you sure you want to delete this client? This action cannot be undone.')">
<form method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}" onsubmit="return confirm('Are you sure you want to delete this client? This action cannot be undone.')">
<button type="submit" class="btn btn-danger w-100">
<i class="fas fa-trash"></i> Delete Client
<i class="fas fa-trash me-2"></i>Delete Client
</button>
</form>
{% endif %}
@@ -160,22 +235,23 @@
<!-- Projects List -->
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-project-diagram"></i> Projects ({{ projects|length }})
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-project-diagram me-2"></i>Projects
<span class="badge badge-soft-secondary ms-2">{{ projects|length }} total</span>
</h6>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> New Project
<a href="{{ url_for('projects.create_project') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-plus me-1"></i> New Project
</a>
{% endif %}
</div>
<div class="card-body">
<div class="card-body p-0">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Project Name</th>
<th>Status</th>
@@ -194,21 +270,21 @@
<strong>{{ project.name }}</strong>
</a>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</small>
<br><small class="text-muted">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</small>
{% endif %}
</td>
<td>
{% if project.status == 'active' %}
<span class="badge bg-success">Active</span>
<span class="badge badge-soft-success">Active</span>
{% else %}
<span class="badge bg-secondary">Archived</span>
<span class="badge badge-soft-secondary">Archived</span>
{% endif %}
</td>
<td>
{% if project.billable %}
<span class="badge bg-primary">Yes</span>
<span class="badge badge-soft-primary">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
<span class="badge badge-soft-secondary">No</span>
{% endif %}
</td>
<td>
@@ -229,12 +305,12 @@
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-outline-primary" title="View">
class="btn btn-sm btn-action btn-action--view" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
@@ -247,14 +323,16 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Projects Found</h4>
<p class="text-muted">This client doesn't have any projects yet.</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Create First Project
</a>
{% endif %}
<div class="empty-state">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No projects found</h5>
<p class="text-muted mb-3">This client doesn't have any projects yet.</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Create First Project
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
@@ -262,4 +340,24 @@
</div>
</div>
</div>
<style>
.status-badge-large { padding: 8px 16px; border-radius: 25px; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.section-title { font-size: 16px; font-weight: 600; border-bottom: 2px solid var(--primary-color); padding-bottom: 8px; }
.detail-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-color); }
.detail-label { font-weight: 600; color: var(--text-secondary); }
.detail-value { font-weight: 600; color: var(--text-primary); }
.content-box { background: var(--light-color); padding: 16px; border-radius: 8px; border-left: 4px solid var(--primary-color); line-height: 1.6; }
.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) !important; }
.summary-icon { width: 44px; height: 44px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; }
.summary-label { font-size: 12px; color: var(--text-secondary); font-weight: 600; letter-spacing: 0.4px; text-transform: uppercase; }
.summary-value { font-size: 20px; font-weight: 800; color: var(--text-primary); }
.empty-state { padding: 2rem; }
@media (max-width: 768px) {
.btn-group { flex-direction: column; width: 100%; }
.btn-group .btn { margin-bottom: 8px; border-radius: 6px !important; }
.detail-row { flex-direction: column; align-items: flex-start; gap: 4px; }
.status-badge-large { font-size: 12px; padding: 6px 12px; }
}
</style>
{% endblock %}
+1 -1
View File
@@ -131,7 +131,7 @@
</td>
<td>
<div class="user-info">
<div class="user-name">{{ entry.user.username }}</div>
<div class="user-name">{{ entry.user.display_name }}</div>
<small class="text-muted">{{ entry.start_time.strftime('%I:%M %p') }}</small>
</div>
</td>
+1 -1
View File
@@ -151,7 +151,7 @@
<div class="info-section">
<div class="info-item">
<div class="info-label">Created by</div>
<div class="info-value">{{ invoice.creator.username }}</div>
<div class="info-value">{{ invoice.creator.display_name }}</div>
</div>
<div class="info-item">
<div class="info-label">Created</div>
+208 -99
View File
@@ -6,17 +6,77 @@
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4 flex-column flex-md-row">
<h1 class="h3 mb-0 mb-3 mb-md-0">
<i class="fas fa-project-diagram text-primary"></i> Projects
</h1>
{% if current_user.is_admin %}
<div>
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus"></i> New Project
</a>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-project-diagram text-primary"></i>
Projects
</h1>
<span class="badge bg-primary fs-6">{{ projects|length }} total</span>
</div>
<div class="d-flex gap-2">
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-1"></i> New Project
</a>
{% endif %}
</div>
</div>
</div>
</div>
{# Summary cards similar to invoices #}
{% set _active = 0 %}
{% set _archived = 0 %}
{% set _total_hours = 0 %}
{% for p in projects %}
{% if p.status == 'active' %}{% set _active = _active + 1 %}{% endif %}
{% if p.status == 'archived' %}{% set _archived = _archived + 1 %}{% endif %}
{% set _total_hours = _total_hours + (p.total_hours or 0) %}
{% endfor %}
<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 d-flex align-items-center">
<div class="summary-icon bg-primary bg-opacity-10 text-primary"><i class="fas fa-list"></i></div>
<div class="ms-3">
<div class="summary-label">Total Projects</div>
<div class="summary-value">{{ projects|length }}</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 d-flex align-items-center">
<div class="summary-icon bg-success bg-opacity-10 text-success"><i class="fas fa-check-circle"></i></div>
<div class="ms-3">
<div class="summary-label">Active</div>
<div class="summary-value">{{ _active }}</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 d-flex align-items-center">
<div class="summary-icon bg-secondary bg-opacity-10 text-secondary"><i class="fas fa-archive"></i></div>
<div class="ms-3">
<div class="summary-label">Archived</div>
<div class="summary-value">{{ _archived }}</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 d-flex align-items-center">
<div class="summary-icon bg-info bg-opacity-10 text-info"><i class="fas fa-hourglass-half"></i></div>
<div class="ms-3">
<div class="summary-label">Total Hours</div>
<div class="summary-value">{{ '%.1f'|format(_total_hours) }}h</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -71,24 +131,35 @@
<!-- Projects List -->
<div class="row">
<div class="col-12">
<div class="card mobile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-list"></i> Projects ({{ projects|length }})
</h5>
<div class="card shadow-sm border-0 mobile-card">
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Projects
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 250px;">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput"
placeholder="Search projects...">
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0" id="projectsTable">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Client</th>
<th>Status</th>
<th>Hours</th>
<th>Rate</th>
<th>Actions</th>
<th class="border-0">Project</th>
<th class="border-0">Client</th>
<th class="border-0">Status</th>
<th class="border-0">Hours</th>
<th class="border-0">Rate</th>
<th class="border-0 text-center">Actions</th>
</tr>
</thead>
<tbody>
@@ -105,13 +176,13 @@
</div>
</td>
<td data-label="Client">
<span class="badge rounded-pill bg-info-subtle text-info-emphasis border border-info-subtle">{{ project.client }}</span>
<span class="project-badge">{{ project.client }}</span>
</td>
<td data-label="Status">
{% if project.status == 'active' %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle">Active</span>
<span class="status-badge bg-success text-white">Active</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">Archived</span>
<span class="status-badge bg-secondary text-white">Archived</span>
{% endif %}
</td>
<td data-label="Hours" class="align-middle" style="min-width:180px;">
@@ -128,7 +199,7 @@
</td>
<td data-label="Rate" class="text-end">
{% if project.hourly_rate %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle rounded-pill">${{ "%.2f"|format(project.hourly_rate) }}/h</span>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle rounded-pill">{{ currency }}{{ "%.2f"|format(project.hourly_rate) }}/h</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
@@ -158,16 +229,16 @@
</div>
{% else %}
<div class="text-center py-5">
<div class="mb-4">
<i class="fas fa-project-diagram fa-3x text-muted opacity-50"></i>
<div class="empty-state">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No projects found</h5>
<p class="text-muted mb-4">Create your first project to get started.</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-2"></i> Create Project
</a>
{% endif %}
</div>
<h5 class="text-muted mb-3">No projects found</h5>
<p class="text-muted mb-4">Create your first project to get started</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-2"></i>Create Project
</a>
{% endif %}
</div>
{% endif %}
</div>
@@ -208,74 +279,112 @@
</div>
</div>
<style>
.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) !important;
}
.summary-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.summary-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-value {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.project-badge {
background: var(--light-color);
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.empty-state {
padding: 2rem;
}
@media (max-width: 768px) {
.summary-card { margin-bottom: 1rem; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Improve mobile table responsiveness
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Client');
} else if (index === 2) {
cell.setAttribute('data-label', 'Status');
} else if (index === 3) {
cell.setAttribute('data-label', 'Hours');
} else if (index === 4) {
cell.setAttribute('data-label', 'Rate');
} else if (index === 5) {
cell.setAttribute('data-label', 'Actions');
} else if (index === 6) {
cell.setAttribute('data-label', 'Actions');
}
});
});
// Improve touch targets
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
// Initialize DataTable
const table = $('#projectsTable').DataTable({
order: [[0, 'asc']],
pageLength: 25,
responsive: true,
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
'<"row"<"col-sm-12"tr>>' +
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
columnDefs: [
{ orderable: false, targets: -1 }
]
});
// Custom search
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('keyup', function() {
table.search(this.value).draw();
});
}
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
// Re-apply mobile table improvements
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Client');
} else if (index === 2) {
cell.setAttribute('data-label', 'Status');
} else if (index === 3) {
cell.setAttribute('data-label', 'Hours');
} else if (index === 4) {
cell.setAttribute('data-label', 'Rate');
} else if (index === 5) {
cell.setAttribute('data-label', 'Actions');
} else if (index === 6) {
cell.setAttribute('data-label', 'Actions');
}
});
});
}
// Fill progress bars
document.querySelectorAll('#projectsTable .progress-bar').forEach(el => {
const pct = el.getAttribute('data-pct') || 0;
el.style.width = pct + '%';
});
});
function filterByStatus(status) {
const table = $('#projectsTable').DataTable();
if (status === 'all') {
table.column(2).search('').draw();
} else {
// Match the rendered badge text
const regex = status === 'active' ? '^Active$' : '^Archived$';
table.column(2).search(regex, true, false).draw();
}
document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('active'));
const active = document.querySelector(`[onclick="filterByStatus('${status}')"]`);
if (active) active.classList.add('active');
}
// Function to show delete project modal
function showDeleteProjectModal(projectId, projectName) {
document.getElementById('deleteProjectName').textContent = projectName;
@@ -287,7 +396,7 @@ function showDeleteProjectModal(projectId, projectName) {
document.addEventListener('DOMContentLoaded', function() {
const deleteForm = document.getElementById('deleteProjectForm');
if (deleteForm) {
deleteForm.addEventListener('submit', function(e) {
deleteForm.addEventListener('submit', function() {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
submitBtn.disabled = true;
+100 -91
View File
@@ -7,25 +7,38 @@
<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('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item active">{{ project.name }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-project-diagram text-primary"></i> {{ project.name }}
</h1>
<div class="d-flex align-items-center">
<div>
<nav aria-label="breadcrumb" class="mb-1">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item active">{{ project.name }}</li>
</ol>
</nav>
<h1 class="h3 mb-0 me-3">
<i class="fas fa-project-diagram text-primary"></i>
{{ project.name }}
</h1>
</div>
<div class="ms-3">
{% if project.status == 'active' %}
<span class="status-badge bg-success text-white"><i class="fas fa-check-circle me-2"></i>Active</span>
{% else %}
<span class="status-badge bg-secondary text-white"><i class="fas fa-archive me-2"></i>Archived</span>
{% endif %}
</div>
</div>
<div>
<div class="btn-group" role="group">
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn btn-secondary">
<i class="fas fa-edit"></i> Edit
<i class="fas fa-edit me-1"></i> Edit
</a>
{% endif %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-clock"></i> Start Timer
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back
</a>
<a href="{{ url_for('timer.start_timer_for_project', project_id=project.id) }}" class="btn btn-primary">
<i class="fas fa-play me-1"></i> Start Timer
</a>
</div>
</div>
@@ -35,74 +48,51 @@
<!-- Project Details -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Project Details
</h5>
</div>
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{{ project.name }}</dd>
<dt class="col-sm-4">Client:</dt>
<dd class="col-sm-8">
{% if project.client_obj %}
<a href="{{ url_for('clients.view_client', client_id=project.client_id) }}">
{{ project.client_obj.name }}
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
{% if project.status == 'active' %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Archived</span>
{% endif %}
</dd>
<dt class="col-sm-4">Created:</dt>
<dd class="col-sm-8">{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
</dl>
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-info-circle me-2"></i>General
</h6>
<div class="detail-row"><span class="detail-label">Name</span><span class="detail-value">{{ project.name }}</span></div>
<div class="detail-row"><span class="detail-label">Client</span>
<span class="detail-value">
{% if project.client_obj %}
<a href="{{ url_for('clients.view_client', client_id=project.client_id) }}">{{ project.client_obj.name }}</a>
{% else %}<span class="text-muted">-</span>{% endif %}
</span>
</div>
<div class="detail-row"><span class="detail-label">Status</span>
<span class="detail-value">{% if project.status == 'active' %}Active{% else %}Archived{% endif %}</span>
</div>
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value">{{ project.created_at.strftime('%B %d, %Y') }}</span></div>
</div>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Billable:</dt>
<dd class="col-sm-8">
{% if project.billable %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</dd>
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-cog me-2"></i>Billing
</h6>
<div class="detail-row"><span class="detail-label">Billable</span>
<span class="detail-value">{% if project.billable %}Yes{% else %}No{% endif %}</span>
</div>
{% if project.billable and project.hourly_rate %}
<dt class="col-sm-4">Hourly Rate:</dt>
<dd class="col-sm-8">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</dd>
<div class="detail-row"><span class="detail-label">Hourly Rate</span><span class="detail-value">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</span></div>
{% endif %}
{% if project.billing_ref %}
<dt class="col-sm-4">Billing Ref:</dt>
<dd class="col-sm-8">{{ project.billing_ref }}</dd>
<div class="detail-row"><span class="detail-label">Billing Ref</span><span class="detail-value">{{ project.billing_ref }}</span></div>
{% endif %}
<dt class="col-sm-4">Last Updated:</dt>
<dd class="col-sm-8">{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
</dl>
<div class="detail-row"><span class="detail-label">Last Updated</span><span class="detail-value">{{ project.updated_at.strftime('%B %d, %Y') }}</span></div>
</div>
</div>
</div>
{% if project.description %}
<div class="mt-3">
<h6>Description:</h6>
<p class="text-muted">{{ project.description }}</p>
<h6 class="section-title text-primary mb-2">Description</h6>
<div class="content-box">{{ project.description }}</div>
</div>
{% endif %}
</div>
@@ -110,11 +100,11 @@
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> Statistics
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-light py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-bar me-2"></i>Statistics
</h6>
</div>
<div class="card-body">
<div class="row text-center">
@@ -137,11 +127,11 @@
</div>
{% if project.billable and project.hourly_rate %}
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-users"></i> User Breakdown
</h5>
<div class="card mt-3 shadow-sm border-0">
<div class="card-header bg-light py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-users me-2"></i>User Breakdown
</h6>
</div>
<div class="card-body">
{% for user_total in project.get_user_totals() %}
@@ -200,7 +190,7 @@
<div class="task-meta small">
{% if task.assigned_user %}
<div class="text-muted mb-1">
<i class="fas fa-user"></i> {{ task.assigned_user.username }}
<i class="fas fa-user"></i> {{ task.assigned_user.display_name }}
</div>
{% endif %}
{% if task.due_date %}
@@ -237,7 +227,7 @@
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('timer.start_timer', project_id=task.project.id, task_id=task.id) }}"
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project.id, task_id=task.id) }}"
class="btn btn-outline-success btn-sm">
<i class="fas fa-play"></i>
</a>
@@ -278,16 +268,14 @@
<!-- Time Entries -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-clock"></i> Time Entries
</h5>
<div>
<a href="{{ url_for('reports.project_report', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-chart-line"></i> View Report
</a>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-clock me-2"></i>Time Entries
</h6>
<a href="{{ url_for('reports.project_report', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-chart-line"></i> View Report
</a>
</div>
<div class="card-body">
{% if entries %}
@@ -308,7 +296,7 @@
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>{{ entry.user.display_name }}</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
@@ -514,4 +502,25 @@ document.addEventListener('DOMContentLoaded', function() {
flex: 1;
}
</style>
<style>
.status-badge {
padding: 8px 16px;
border-radius: 25px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.invoice-section { padding: 12px 0; }
.section-title { font-size: 16px; font-weight: 600; border-bottom: 2px solid var(--primary-color); padding-bottom: 8px; }
.detail-row { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding:8px 0; border-bottom:1px solid var(--border-color); }
.detail-label { font-weight:600; color:var(--text-secondary); }
.detail-value { font-weight:600; color:var(--text-primary); }
.content-box { background: var(--light-color); padding: 16px; border-radius: 8px; border-left: 4px solid var(--primary-color); line-height: 1.6; }
@media (max-width:768px){
.detail-row{flex-direction:column; align-items:flex-start; gap:4px;}
}
</style>
{% endblock %}
+81 -44
View File
@@ -21,39 +21,71 @@
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ "%.1f"|format(summary.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ "%.1f"|format(summary.billable_hours) }}h</h4>
<p class="text-muted mb-0">Billable Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ summary.active_projects }}</h4>
<p class="text-muted mb-0">Active Projects</p>
<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">Active Projects</div>
<div class="summary-value">{{ summary.active_projects }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-users fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ summary.total_users }}</h4>
<p class="text-muted mb-0">Users</p>
<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-users"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Users</div>
<div class="summary-value">{{ summary.total_users }}</div>
</div>
</div>
</div>
</div>
</div>
@@ -62,8 +94,8 @@
<!-- Report Options -->
<div class="row">
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<div class="card h-100 border-0 shadow-sm hover-lift">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-project-diagram"></i> Project Reports
</h5>
@@ -74,14 +106,17 @@
<a href="{{ url_for('reports.project_report') }}" class="btn btn-primary">
<i class="fas fa-chart-bar"></i> Project Report
</a>
<a href="{{ url_for('reports.task_report') }}" class="btn btn-outline-primary mt-2">
<i class="fas fa-tasks"></i> Finished Tasks Report
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<div class="card h-100 border-0 shadow-sm hover-lift">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-user"></i> User Reports
</h5>
@@ -98,8 +133,8 @@
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<div class="card h-100 border-0 shadow-sm hover-lift">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-calendar-alt"></i> Summary Report
</h5>
@@ -116,8 +151,8 @@
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<div class="card h-100 border-0 shadow-sm hover-lift">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-chart-line"></i> Visual Analytics
</h5>
@@ -134,8 +169,8 @@
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<div class="card h-100 border-0 shadow-sm hover-lift">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-download"></i> Data Export
</h5>
@@ -155,17 +190,17 @@
<!-- Recent Activity -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-history"></i> Recent Activity
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
@@ -178,7 +213,7 @@
<tbody>
{% for entry in recent_entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
@@ -208,10 +243,12 @@
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-clock fa-2x text-muted mb-3"></i>
<h5 class="text-muted">No Recent Activity</h5>
<p class="text-muted">No time entries have been recorded recently.</p>
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-clock fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Recent Activity</h5>
<p class="text-muted">No time entries have been recorded recently.</p>
</div>
</div>
{% endif %}
</div>
+76 -42
View File
@@ -30,8 +30,8 @@
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> Filters
</h5>
@@ -65,7 +65,7 @@
<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.username }}
{{ user.display_name }}
</option>
{% endfor %}
</select>
@@ -86,39 +86,71 @@
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ "%.1f"|format(summary.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ "%.1f"|format(summary.billable_hours) }}h</h4>
<p class="text-muted mb-0">Billable Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ currency }} {{ "%.2f"|format(summary.total_billable_amount) }}</h4>
<p class="text-muted mb-0">Billable Amount</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ summary.projects_count }}</h4>
<p class="text-muted mb-0">Projects</p>
<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>
@@ -127,17 +159,17 @@
<!-- Project Breakdown -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-list"></i> Project Breakdown ({{ projects_data|length }})
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if projects_data %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Client</th>
@@ -211,16 +243,18 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-chart-bar fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Data Found</h4>
<p class="text-muted">
<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>
</p>
</div>
</div>
{% endif %}
</div>
@@ -232,16 +266,16 @@
{% if entries %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-clock"></i> Time Entries ({{ entries|length }})
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
@@ -256,7 +290,7 @@
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
+52 -26
View File
@@ -24,30 +24,54 @@
<!-- Key Metrics -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-sun fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ "%.1f"|format(today_hours) }}h</h4>
<p class="text-muted mb-0">Today</p>
<div class="col-md-4 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-sun"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Today</div>
<div class="summary-value">{{ "%.1f"|format(today_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-calendar-week fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ "%.1f"|format(week_hours) }}h</h4>
<p class="text-muted mb-0">Last 7 Days</p>
<div class="col-md-4 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-calendar-week"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Last 7 Days</div>
<div class="summary-value">{{ "%.1f"|format(week_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-calendar-alt fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ "%.1f"|format(month_hours) }}h</h4>
<p class="text-muted mb-0">Last 30 Days</p>
<div class="col-md-4 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-calendar-alt"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Last 30 Days</div>
<div class="summary-value">{{ "%.1f"|format(month_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
@@ -56,17 +80,17 @@
<!-- Top Projects -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-project-diagram"></i> Top Projects ({{ project_stats|length }})
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if project_stats %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Client</th>
@@ -90,9 +114,11 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-chart-bar fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Data Found</h4>
<p class="text-muted">No time entries available for the selected period.</p>
<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">No time entries available for the selected period.</p>
</div>
</div>
{% endif %}
</div>
+202
View File
@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block title %}Finished Tasks Report - {{ app_name }}{% 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">Finished Tasks</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-tasks text-primary"></i> Finished Tasks Report
</h1>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> Filters
</h5>
</div>
<div class="card-body">
<form method="GET" class="row g-3">
<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="{{ start_date or '' }}">
</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="{{ end_date or '' }}">
</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 selected_project|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 selected_user|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.task_report') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> Clear
</a>
</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-tasks"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Finished Tasks</div>
<div class="summary-value">{{ summary.tasks_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-success bg-opacity-10 text-success">
<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">{{ "%.2f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Finished Tasks Table -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-list"></i> Finished Tasks ({{ tasks|length }})
</h5>
</div>
<div class="card-body p-0">
{% if tasks %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Task</th>
<th>Project</th>
<th>Assignee</th>
<th>Completed</th>
<th>Hours</th>
<th>Entries</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for row in tasks %}
<tr>
<td>
<div>
<strong>{{ row.task.name }}</strong>
{% if row.task.description %}
<br><small class="text-muted">{{ row.task.description[:60] }}{% if row.task.description|length > 60 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=row.project.id) }}">
{{ row.project.name }}
</a>
</td>
<td>
{% if row.assignee %}
{{ row.assignee.display_name }}
{% else %}
<span class="text-muted">Unassigned</span>
{% endif %}
</td>
<td>
{% if row.completed_at %}
{{ row.completed_at.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td><strong>{{ "%.2f"|format(row.hours) }}h</strong></td>
<td>{{ row.entries_count }}</td>
<td>
<a href="{{ url_for('tasks.view_task', task_id=row.task.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-tasks fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Finished Tasks Found</h5>
<p class="text-muted">Try adjusting your filters.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+75 -41
View File
@@ -30,8 +30,8 @@
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> Filters
</h5>
@@ -54,7 +54,7 @@
<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.username }}
{{ user.display_name }}
</option>
{% endfor %}
</select>
@@ -86,39 +86,71 @@
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ "%.1f"|format(summary.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ "%.1f"|format(summary.billable_hours) }}h</h4>
<p class="text-muted mb-0">Billable Hours</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-users fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ summary.users_count }}</h4>
<p class="text-muted mb-0">Users</p>
<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">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ summary.projects_count }}</h4>
<p class="text-muted mb-0">Projects</p>
<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>
@@ -127,17 +159,17 @@
<!-- User Breakdown -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-list"></i> User Breakdown ({{ user_totals|length }})
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if user_totals %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Total Hours</th>
@@ -163,9 +195,11 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Data Found</h4>
<p class="text-muted">Try adjusting your filters.</p>
<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>
@@ -177,16 +211,16 @@
{% if entries %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-clock"></i> Time Entries ({{ entries|length }})
</h5>
</div>
<div class="card-body">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
@@ -201,7 +235,7 @@
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
+141 -65
View File
@@ -3,103 +3,130 @@
{% block title %}Log Time - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="fas fa-plus me-2 text-primary"></i>
<h5 class="mb-0">Log Time Manually</h5>
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-clock text-primary"></i>
Log Time
</h1>
</div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header py-3 bg-primary text-white">
<h6 class="m-0 font-weight-bold">
<i class="fas fa-plus me-2"></i>Manual Entry
</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('timer.manual_entry') }}">
<div class="mb-4">
<label for="project_id" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>Project *
</label>
<select class="form-select" id="project_id" name="project_id" required>
<option value="">Select a project...</option>
{% set selected_project_id = (request.form.get('project_id') or '')|int %}
{% for project in projects %}
<option value="{{ project.id }}" {% if project.id == selected_project_id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label for="task_id" class="form-label fw-semibold">
<i class="fas fa-tasks me-1"></i>Task (optional)
</label>
{% set preselected_task_id = request.form.get('task_id') or request.args.get('task_id') %}
<select class="form-select" id="task_id" name="task_id" data-selected-task-id="{{ preselected_task_id or '' }}" disabled>
<option value="">No task</option>
</select>
<div class="form-text">Tasks will be loaded for the selected project.</div>
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<select class="form-select" id="project_id" name="project_id" required>
<option value=""></option>
{% set selected_project_id = (request.form.get('project_id') or '')|int %}
{% for project in projects %}
<option value="{{ project.id }}" {% if project.id == selected_project_id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
<label for="project_id"><i class="fas fa-project-diagram me-1"></i>Project *</label>
<div class="form-text">Select the project to log time for</div>
</div>
</div>
<div class="col-md-6">
{% set preselected_task_id = request.form.get('task_id') or request.args.get('task_id') %}
<div class="form-floating mb-3">
<select class="form-select" id="task_id" name="task_id" data-selected-task-id="{{ preselected_task_id or '' }}" disabled>
<option value=""></option>
</select>
<label for="task_id"><i class="fas fa-tasks me-1"></i>Task (optional)</label>
<div class="form-text">Tasks load after selecting a project</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="fas fa-play me-1"></i>Start *
</label>
<div class="row g-2">
<div class="col-6">
<input type="date" class="form-control" name="start_date" id="start_date" required value="{{ request.form.get('start_date','') }}">
</div>
<div class="col-6">
<input type="time" class="form-control" name="start_time" id="start_time" required value="{{ request.form.get('start_time','') }}">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="mb-2 fw-semibold text-primary"><i class="fas fa-play me-1"></i>Start *</div>
<div class="row g-2">
<div class="col-6">
<div class="form-floating">
<input type="date" class="form-control" name="start_date" id="start_date" required value="{{ request.form.get('start_date','') }}">
<label for="start_date">Date</label>
</div>
</div>
<div class="col-6">
<div class="form-floating">
<input type="time" class="form-control" name="start_time" id="start_time" required value="{{ request.form.get('start_time','') }}">
<label for="start_time">Time</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="fas fa-stop me-1"></i>End *
</label>
<div class="row g-2">
<div class="col-6">
<input type="date" class="form-control" name="end_date" id="end_date" required value="{{ request.form.get('end_date','') }}">
</div>
<div class="col-6">
<input type="time" class="form-control" name="end_time" id="end_time" required value="{{ request.form.get('end_time','') }}">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="mb-2 fw-semibold text-primary"><i class="fas fa-stop me-1"></i>End *</div>
<div class="row g-2">
<div class="col-6">
<div class="form-floating">
<input type="date" class="form-control" name="end_date" id="end_date" required value="{{ request.form.get('end_date','') }}">
<label for="end_date">Date</label>
</div>
</div>
<div class="col-6">
<div class="form-floating">
<input type="time" class="form-control" name="end_time" id="end_time" required value="{{ request.form.get('end_time','') }}">
<label for="end_time">Time</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-4">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>Notes
</label>
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="What did you work on?">{{ request.form.get('notes','') }}</textarea>
<div class="form-floating my-3">
<textarea class="form-control" id="notes" name="notes" style="height: 100px" placeholder="What did you work on?">{{ request.form.get('notes','') }}</textarea>
<label for="notes"><i class="fas fa-sticky-note me-1"></i>Notes</label>
</div>
<div class="row g-3">
<div class="row g-3 align-items-center">
<div class="col-12 col-md-8">
<div class="mb-4">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>Tags
</label>
<div class="form-floating mb-3 mb-md-0">
<input type="text" class="form-control" id="tags" name="tags" placeholder="tag1, tag2, tag3" value="{{ request.form.get('tags','') }}">
<label for="tags"><i class="fas fa-tags me-1"></i>Tags</label>
<div class="form-text">Separate tags with commas</div>
</div>
</div>
<div class="col-12 col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4 w-100">
<div class="col-12 col-md-4">
<div class="form-check form-switch mt-2 d-flex align-items-center">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% else %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<label class="form-check-label fw-semibold ms-2" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between flex-column flex-md-row">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mb-2 mb-md-0">
<i class="fas fa-arrow-left me-1"></i>Back
<div class="d-flex justify-content-between flex-column flex-md-row mt-3">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary mb-2 mb-md-0">
<i class="fas fa-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Save Entry
@@ -109,9 +136,58 @@
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header py-3 bg-light">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-lightbulb me-2"></i>Quick Tips
</h6>
</div>
<div class="card-body">
<div class="tip-item mb-3 d-flex gap-3">
<div class="tip-icon text-primary"><i class="fas fa-tasks"></i></div>
<div class="tip-content">
<strong>Use Tasks</strong>
<p class="small text-muted mb-0">Categorize time by selecting a task after choosing a project.</p>
</div>
</div>
<div class="tip-item mb-3 d-flex gap-3">
<div class="tip-icon text-success"><i class="fas fa-dollar-sign"></i></div>
<div class="tip-content">
<strong>Billable Time</strong>
<p class="small text-muted mb-0">Enable billable to include this entry in invoices.</p>
</div>
</div>
<div class="tip-item d-flex gap-3">
<div class="tip-icon text-info"><i class="fas fa-tags"></i></div>
<div class="tip-content">
<strong>Tag Entries</strong>
<p class="small text-muted mb-0">Add tags to filter entries in reports later.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label,
.form-floating > .form-select:focus ~ label,
.form-floating > .form-select:not([value=""]) ~ label {
color: var(--primary-color);
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
}
.tip-icon { font-size: 18px; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Set default dates to today