Merge pull request #136 from DRYTRIX/Feat-Favourite-Projects

feat: add user favorite projects functionality with CSV export enhanc…
This commit is contained in:
Dries Peeters
2025-10-23 21:15:38 +02:00
committed by GitHub
51 changed files with 1727 additions and 10558 deletions

View File

@@ -21,6 +21,7 @@ from .project_cost import ProjectCost
from .kanban_column import KanbanColumn
from .time_entry_template import TimeEntryTemplate
from .activity import Activity
from .user_favorite_project import UserFavoriteProject
__all__ = [
"User",
@@ -50,4 +51,5 @@ __all__ = [
"KanbanColumn",
"TimeEntryTemplate",
"Activity",
"UserFavoriteProject",
]

View File

@@ -260,9 +260,19 @@ class Project(db.Model):
self.updated_at = datetime.utcnow()
db.session.commit()
def to_dict(self):
def is_favorited_by(self, user):
"""Check if this project is favorited by a specific user"""
from .user import User
if isinstance(user, int):
user_id = user
return self.favorited_by.filter_by(id=user_id).count() > 0
elif isinstance(user, User):
return self.favorited_by.filter_by(id=user.id).count() > 0
return False
def to_dict(self, user=None):
"""Convert project to dictionary for API responses"""
return {
data = {
'id': self.id,
'name': self.name,
'code': self.code,
@@ -287,3 +297,7 @@ class Project(db.Model):
'total_billable_costs': self.total_billable_costs,
'total_project_value': self.total_project_value,
}
# Include favorite status if user is provided
if user:
data['is_favorite'] = self.is_favorited_by(user)
return data

View File

@@ -40,6 +40,7 @@ class User(UserMixin, db.Model):
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan')
favorite_projects = db.relationship('Project', secondary='user_favorite_projects', lazy='dynamic', backref=db.backref('favorited_by', lazy='dynamic'))
def __init__(self, username, role='user', email=None, full_name=None):
self.username = username.lower().strip()
@@ -137,3 +138,33 @@ class User(UserMixin, db.Model):
"""Check whether the user's avatar file exists on disk"""
path = self.get_avatar_path()
return bool(path and os.path.exists(path))
# Favorite projects helpers
def add_favorite_project(self, project):
"""Add a project to user's favorites"""
if not self.is_project_favorite(project):
self.favorite_projects.append(project)
db.session.commit()
def remove_favorite_project(self, project):
"""Remove a project from user's favorites"""
if self.is_project_favorite(project):
self.favorite_projects.remove(project)
db.session.commit()
def is_project_favorite(self, project):
"""Check if a project is in user's favorites"""
from .project import Project
if isinstance(project, int):
project_id = project
return self.favorite_projects.filter_by(id=project_id).count() > 0
elif isinstance(project, Project):
return self.favorite_projects.filter_by(id=project.id).count() > 0
return False
def get_favorite_projects(self, status='active'):
"""Get user's favorite projects, optionally filtered by status"""
query = self.favorite_projects
if status:
query = query.filter_by(status=status)
return query.order_by('name').all()

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from app import db
class UserFavoriteProject(db.Model):
"""Association table for user favorite projects"""
__tablename__ = 'user_favorite_projects'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Unique constraint to prevent duplicate favorites
__table_args__ = (
db.UniqueConstraint('user_id', 'project_id', name='uq_user_project_favorite'),
)
def __repr__(self):
return f'<UserFavoriteProject user_id={self.user_id} project_id={self.project_id}>'
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
}

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColumn, ExtraGood, Activity
from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColumn, ExtraGood, Activity, UserFavoriteProject
from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
@@ -28,14 +28,28 @@ def list_projects():
status = request.args.get('status', 'active')
client_name = request.args.get('client', '').strip()
search = request.args.get('search', '').strip()
favorites_only = request.args.get('favorites', '').lower() == 'true'
query = Project.query
# Filter by favorites if requested
if favorites_only:
# Join with user_favorite_projects table
query = query.join(
UserFavoriteProject,
db.and_(
UserFavoriteProject.project_id == Project.id,
UserFavoriteProject.user_id == current_user.id
)
)
# Filter by status (use Project.status to be explicit)
if status == 'active':
query = query.filter_by(status='active')
query = query.filter(Project.status == 'active')
elif status == 'archived':
query = query.filter_by(status='archived')
query = query.filter(Project.status == 'archived')
elif status == 'inactive':
query = query.filter_by(status='inactive')
query = query.filter(Project.status == 'inactive')
if client_name:
query = query.join(Client).filter(Client.name == client_name)
@@ -49,21 +63,27 @@ def list_projects():
)
)
projects = query.order_by(Project.name).paginate(
# Get all projects for the current page
projects_pagination = query.order_by(Project.name).paginate(
page=page,
per_page=20,
error_out=False
)
# Get user's favorite project IDs for quick lookup in template
favorite_project_ids = {p.id for p in current_user.favorite_projects.all()}
# Get clients for filter dropdown
clients = Client.get_active_clients()
client_list = [c.name for c in clients]
return render_template(
'projects/list.html',
projects=projects.items,
projects=projects_pagination.items,
status=status,
clients=client_list
clients=client_list,
favorite_project_ids=favorite_project_ids,
favorites_only=favorites_only
)
@projects_bp.route('/projects/create', methods=['GET', 'POST'])
@@ -637,6 +657,98 @@ def bulk_status_change():
return redirect(url_for('projects.list_projects'))
# ===== FAVORITE PROJECTS ROUTES =====
@projects_bp.route('/projects/<int:project_id>/favorite', methods=['POST'])
@login_required
def favorite_project(project_id):
"""Add a project to user's favorites"""
project = Project.query.get_or_404(project_id)
try:
# Check if already favorited
if current_user.is_project_favorite(project):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Project is already in favorites')}), 200
flash(_('Project is already in favorites'), 'info')
else:
# Add to favorites
current_user.add_favorite_project(project)
# Log activity
Activity.log(
user_id=current_user.id,
action='favorited',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Added project "{project.name}" to favorites',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Track event
log_event("project.favorited", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.favorited", {"project_id": project.id})
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': _('Project added to favorites')}), 200
flash(_('Project added to favorites'), 'success')
except Exception as e:
current_app.logger.error(f"Error favoriting project: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Failed to add project to favorites')}), 500
flash(_('Failed to add project to favorites'), 'error')
# Redirect back to referrer or project list
return redirect(request.referrer or url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/unfavorite', methods=['POST'])
@login_required
def unfavorite_project(project_id):
"""Remove a project from user's favorites"""
project = Project.query.get_or_404(project_id)
try:
# Check if not favorited
if not current_user.is_project_favorite(project):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Project is not in favorites')}), 200
flash(_('Project is not in favorites'), 'info')
else:
# Remove from favorites
current_user.remove_favorite_project(project)
# Log activity
Activity.log(
user_id=current_user.id,
action='unfavorited',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Removed project "{project.name}" from favorites',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Track event
log_event("project.unfavorited", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.unfavorited", {"project_id": project.id})
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': _('Project removed from favorites')}), 200
flash(_('Project removed from favorites'), 'success')
except Exception as e:
current_app.logger.error(f"Error unfavoriting project: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Failed to remove project from favorites')}), 500
flash(_('Failed to remove project from favorites'), 'error')
# Redirect back to referrer or project list
return redirect(request.referrer or url_for('projects.list_projects'))
# ===== PROJECT COSTS ROUTES =====
@projects_bp.route('/projects/<int:project_id>/costs')

View File

@@ -1,8 +1,9 @@
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, log_event, track_event
from app.models import User, Project, TimeEntry, Settings, Task, ProjectCost
from app.models import User, Project, TimeEntry, Settings, Task, ProjectCost, Client
from datetime import datetime, timedelta
from sqlalchemy import or_
import csv
import io
import pytz
@@ -283,16 +284,48 @@ def user_report():
selected_user=user_id,
selected_project=project_id)
@reports_bp.route('/reports/export/form')
@login_required
def export_form():
"""Display CSV export form with filter options"""
# Get all users (for admin)
users = []
if current_user.is_admin:
users = User.query.filter_by(is_active=True).order_by(User.username).all()
# Get all active projects
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
# Get all active clients
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
# Set default date range (last 30 days)
default_end_date = datetime.utcnow().strftime('%Y-%m-%d')
default_start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
return render_template('reports/export_form.html',
users=users,
projects=projects,
clients=clients,
default_start_date=default_start_date,
default_end_date=default_end_date)
@reports_bp.route('/reports/export/csv')
@login_required
def export_csv():
"""Export time entries as CSV"""
"""Export time entries as CSV with enhanced filters"""
start_time = time.time() # Start performance tracking
# Get all filter parameters
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
user_id = request.args.get('user_id', type=int)
project_id = request.args.get('project_id', type=int)
task_id = request.args.get('task_id', type=int)
client_id = request.args.get('client_id', type=int)
billable = request.args.get('billable') # 'yes', 'no', or 'all'
source = request.args.get('source') # 'manual', 'auto', or 'all'
tags = request.args.get('tags', '').strip()
# Parse dates
if not start_date:
@@ -336,11 +369,11 @@ def export_csv():
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter)
# Write header
# Write header with task column
writer.writerow([
'ID', 'User', 'Project', 'Client', 'Start Time', 'End Time',
'ID', 'User', 'Project', 'Client', 'Task', 'Start Time', 'End Time',
'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags',
'Source', 'Billable', 'Created At'
'Source', 'Billable', 'Created At', 'Updated At'
])
# Write data
@@ -350,6 +383,7 @@ def export_csv():
entry.user.display_name,
entry.project.name,
entry.project.client,
entry.task.name if entry.task else '',
entry.start_time.isoformat(),
entry.end_time.isoformat() if entry.end_time else '',
entry.duration_hours,
@@ -358,24 +392,47 @@ def export_csv():
entry.tags or '',
entry.source,
'Yes' if entry.billable else 'No',
entry.created_at.isoformat()
entry.created_at.isoformat(),
entry.updated_at.isoformat() if entry.updated_at else ''
])
output.seek(0)
# Create filename
filename = f'timetracker_export_{start_date}_to_{end_date}.csv'
# Create filename with filters indication
filename_parts = [f'timetracker_export_{start_date}_to_{end_date}']
if project_id:
filename_parts.append('project')
if client_id:
filename_parts.append('client')
if task_id:
filename_parts.append('task')
filename = '_'.join(filename_parts) + '.csv'
# Track CSV export event
# Track CSV export event with enhanced metadata
log_event("export.csv",
user_id=current_user.id,
export_type="time_entries",
num_rows=len(entries),
date_range_days=(end_dt - start_dt).days)
date_range_days=(end_dt - start_dt).days,
filters_applied={
'user_id': user_id,
'project_id': project_id,
'task_id': task_id,
'client_id': client_id,
'billable': billable,
'source': source,
'tags': tags
})
track_event(current_user.id, "export.csv", {
"export_type": "time_entries",
"num_rows": len(entries),
"date_range_days": (end_dt - start_dt).days
"date_range_days": (end_dt - start_dt).days,
"has_project_filter": project_id is not None,
"has_client_filter": client_id is not None,
"has_task_filter": task_id is not None,
"has_billable_filter": billable is not None and billable != 'all',
"has_source_filter": source is not None and source != 'all',
"has_tags_filter": bool(tags)
})
# Track performance

View File

@@ -16,7 +16,7 @@
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Projects</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4" data-filter-form>
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4" data-filter-form>
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
@@ -39,8 +39,15 @@
{% endfor %}
</select>
</div>
<div>
<label for="favorites" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Show</label>
<select name="favorites" id="favorites" class="form-input">
<option value="">All Projects</option>
<option value="true" {% if favorites_only %}selected{% endif %}>⭐ Favorites Only</option>
</select>
</div>
<div class="self-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">Filter</button>
</div>
</form>
</div>
@@ -78,6 +85,7 @@
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllProjects()">
</th>
{% endif %}
<th class="p-4 w-10"></th>
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Client</th>
<th class="p-4" data-sortable>Status</th>
@@ -89,13 +97,24 @@
</thead>
<tbody>
{% for project in projects %}
<tr class="border-b border-border-light dark:border-border-dark">
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
{% if current_user.is_admin %}
<td class="p-4">
<input type="checkbox" class="project-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ project.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
<td class="p-4">{{ project.name }}</td>
<td class="p-4 text-center">
{% set is_fav = favorite_project_ids and project.id in favorite_project_ids %}
<button type="button"
class="favorite-btn text-xl hover:scale-110 transition-transform"
data-project-id="{{ project.id }}"
data-is-favorited="{{ 'true' if is_fav else 'false' }}"
onclick="toggleFavorite({{ project.id }}, this)"
title="{{ 'Remove from favorites' if is_fav else 'Add to favorites' }}">
<i class="{{ 'fas fa-star text-yellow-500' if is_fav else 'far fa-star text-gray-400' }}"></i>
</button>
</td>
<td class="p-4 font-medium">{{ project.name }}</td>
<td class="p-4">{{ project.client }}</td>
<td class="p-4">
{% if project.status == 'active' %}
@@ -246,5 +265,118 @@ function submitBulkStatusChange(){
});
form.submit();
}
// Favorite project functionality
function toggleFavorite(projectId, button) {
// Font Awesome converts <i> to <svg>, so we need to look for either
const icon = button.querySelector('i, svg');
// Safety check
if (!icon) {
console.error('Star icon not found in button');
console.log('Button HTML:', button.innerHTML);
return;
}
// Check if favorited by looking at classes (works for both <i> and <svg>)
const isFavorited = icon.classList.contains('fa-star') &&
(icon.classList.contains('fas') || icon.getAttribute('data-prefix') === 'fas');
const action = isFavorited ? 'unfavorite' : 'favorite';
// Disable button to prevent double clicks
button.disabled = true;
// Optimistic UI update - toggle classes
if (isFavorited) {
// Change to unfilled star
icon.classList.remove('fas', 'text-yellow-500');
icon.classList.add('far', 'text-gray-400');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'far');
}
button.title = 'Add to favorites';
} else {
// Change to filled star
icon.classList.remove('far', 'text-gray-400');
icon.classList.add('fas', 'text-yellow-500');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'fas');
}
button.title = 'Remove from favorites';
}
// Send AJAX request
fetch(`/projects/${projectId}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Success - show toast notification
const message = action === 'favorite' ? 'Project added to favorites' : 'Project removed from favorites';
if (window.toastManager) {
window.toastManager.success(message, 'Success', 3000);
} else if (window.showToast) {
window.showToast(message, 'success');
}
} else {
// Revert UI on error
if (isFavorited) {
icon.classList.remove('far', 'text-gray-400');
icon.classList.add('fas', 'text-yellow-500');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'fas');
}
button.title = 'Remove from favorites';
} else {
icon.classList.remove('fas', 'text-yellow-500');
icon.classList.add('far', 'text-gray-400');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'far');
}
button.title = 'Add to favorites';
}
if (window.showAlert) {
window.showAlert(data.message || 'Failed to update favorite status');
} else {
alert(data.message || 'Failed to update favorite status');
}
}
})
.catch(error => {
console.error('Error toggling favorite:', error);
// Revert UI on error
if (isFavorited) {
icon.classList.remove('far', 'text-gray-400');
icon.classList.add('fas', 'text-yellow-500');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'fas');
}
button.title = 'Remove from favorites';
} else {
icon.classList.remove('fas', 'text-yellow-500');
icon.classList.add('far', 'text-gray-400');
if (icon.tagName === 'svg') {
icon.setAttribute('data-prefix', 'far');
}
button.title = 'Add to favorites';
}
if (window.showAlert) {
window.showAlert('Failed to update favorite status');
} else {
alert('Failed to update favorite status');
}
})
.finally(() => {
// Re-enable button
button.disabled = false;
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,336 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-file-export mr-2"></i>
Export Time Entries to CSV
</h1>
<a href="{{ url_for('reports.reports') }}" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition inline-block">
<i class="fas fa-arrow-left mr-2"></i>Back to Reports
</a>
</div>
<!-- Info Card -->
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6 rounded">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500 text-xl"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
Use the filters below to customize your CSV export. All filters are optional - leave blank to include all entries within the date range.
</p>
</div>
</div>
</div>
<!-- Export Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-lg">
<form id="exportForm" method="GET" action="{{ url_for('reports.export_csv') }}" class="space-y-6">
<!-- Date Range Section -->
<div>
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-calendar-alt mr-2 text-indigo-500"></i>
Date Range
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date <span class="text-red-500">*</span>
</label>
<input type="date"
name="start_date"
id="start_date"
value="{{ default_start_date }}"
required
class="form-input">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date <span class="text-red-500">*</span>
</label>
<input type="date"
name="end_date"
id="end_date"
value="{{ default_end_date }}"
required
class="form-input">
</div>
</div>
</div>
<hr class="border-gray-200 dark:border-gray-700">
<!-- Filter Section -->
<div>
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-filter mr-2 text-indigo-500"></i>
Filters
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% if current_user.is_admin %}
<!-- User Filter (Admin Only) -->
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-user mr-1"></i> User
</label>
<select name="user_id"
id="user_id"
class="form-input">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<!-- Client Filter -->
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-building mr-1"></i> Client
</label>
<select name="client_id"
id="client_id"
class="form-input">
<option value="">All Clients</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}
</select>
</div>
<!-- Project Filter -->
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-project-diagram mr-1"></i> Project
</label>
<select name="project_id"
id="project_id"
class="form-input">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" data-client-id="{{ project.client_id }}">{{ project.name }} ({{ project.client }})</option>
{% endfor %}
</select>
</div>
<!-- Task Filter -->
<div>
<label for="task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-tasks mr-1"></i> Task
</label>
<select name="task_id"
id="task_id"
class="form-input"
disabled>
<option value="">All Tasks (Select a project first)</option>
</select>
</div>
<!-- Billable Filter -->
<div>
<label for="billable" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-dollar-sign mr-1"></i> Billable Status
</label>
<select name="billable"
id="billable"
class="form-input">
<option value="all">All Entries</option>
<option value="yes">Billable Only</option>
<option value="no">Non-Billable Only</option>
</select>
</div>
<!-- Source Filter -->
<div>
<label for="source" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-compass mr-1"></i> Entry Source
</label>
<select name="source"
id="source"
class="form-input">
<option value="all">All Sources</option>
<option value="manual">Manual Entries</option>
<option value="auto">Timer Entries</option>
</select>
</div>
<!-- Tags Filter -->
<div class="md:col-span-2 lg:col-span-3">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-tags mr-1"></i> Tags (comma-separated)
</label>
<input type="text"
name="tags"
id="tags"
placeholder="e.g., development, meeting, urgent"
class="form-input">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter tags separated by commas. Entries matching any of the tags will be included.
</p>
</div>
</div>
</div>
<hr class="border-gray-200 dark:border-gray-700">
<!-- Action Buttons -->
<div class="flex justify-end space-x-4">
<button type="button"
id="resetBtn"
class="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition">
<i class="fas fa-undo mr-2"></i>Reset Filters
</button>
<button type="submit"
class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition">
<i class="fas fa-download mr-2"></i>Export to CSV
</button>
</div>
</form>
</div>
<!-- Preview Section -->
<div class="mt-6 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-eye mr-2 text-indigo-500"></i>
Export Preview
</h3>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="mb-2"><strong>CSV Format:</strong></p>
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded font-mono text-xs overflow-x-auto">
<div class="text-gray-700 dark:text-gray-300">
ID, User, Project, Client, Task, Start Time, End Time, Duration (hours), Duration (formatted), Notes, Tags, Source, Billable, Created At, Updated At
</div>
</div>
<p class="mt-4 text-xs">
<i class="fas fa-info-circle mr-1"></i>
The CSV file will be downloaded with a filename indicating the date range and applied filters.
</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const clientSelect = document.getElementById('client_id');
const resetBtn = document.getElementById('resetBtn');
const exportForm = document.getElementById('exportForm');
// Load tasks when project is selected
if (projectSelect) {
projectSelect.addEventListener('change', function() {
const projectId = this.value;
if (projectId) {
// Enable task select and load tasks
taskSelect.disabled = true;
taskSelect.innerHTML = '<option value="">Loading tasks...</option>';
// Fetch tasks for the selected project
fetch(`/api/projects/${projectId}/tasks`)
.then(response => response.json())
.then(data => {
taskSelect.innerHTML = '<option value="">All Tasks</option>';
if (data.tasks && data.tasks.length > 0) {
data.tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = task.name;
taskSelect.appendChild(option);
});
taskSelect.disabled = false;
} else {
taskSelect.innerHTML = '<option value="">No tasks available</option>';
taskSelect.disabled = true;
}
})
.catch(error => {
console.error('Error loading tasks:', error);
taskSelect.innerHTML = '<option value="">Error loading tasks</option>';
taskSelect.disabled = true;
});
} else {
// Reset task select
taskSelect.innerHTML = '<option value="">All Tasks (Select a project first)</option>';
taskSelect.disabled = true;
}
});
}
// Sync client and project selections
if (projectSelect && clientSelect) {
projectSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
if (selectedOption && selectedOption.dataset.clientId) {
clientSelect.value = selectedOption.dataset.clientId;
}
});
clientSelect.addEventListener('change', function() {
const clientId = this.value;
if (clientId) {
// Filter projects by client
const projectOptions = projectSelect.querySelectorAll('option');
let foundMatch = false;
projectOptions.forEach(option => {
if (option.dataset.clientId === clientId && !foundMatch) {
projectSelect.value = option.value;
foundMatch = true;
// Trigger change event to load tasks
projectSelect.dispatchEvent(new Event('change'));
}
});
}
});
}
// Reset button functionality
if (resetBtn) {
resetBtn.addEventListener('click', function() {
// Reset all select fields to default
exportForm.reset();
// Reset task select
if (taskSelect) {
taskSelect.innerHTML = '<option value="">All Tasks (Select a project first)</option>';
taskSelect.disabled = true;
}
// Set default dates
document.getElementById('start_date').value = '{{ default_start_date }}';
document.getElementById('end_date').value = '{{ default_end_date }}';
});
}
// Form validation
exportForm.addEventListener('submit', function(e) {
const startDate = document.getElementById('start_date').value;
const endDate = document.getElementById('end_date').value;
if (!startDate || !endDate) {
e.preventDefault();
alert('Please select both start and end dates.');
return false;
}
if (new Date(startDate) > new Date(endDate)) {
e.preventDefault();
alert('Start date must be before or equal to end date.');
return false;
}
return true;
});
});
</script>
{% endblock %}

View File

@@ -20,7 +20,9 @@
<a href="{{ url_for('reports.user_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">User Report</a>
<a href="{{ url_for('reports.summary_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">Summary Report</a>
<a href="{{ url_for('reports.task_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">Task Report</a>
<a href="{{ url_for('reports.export_csv') }}" class="bg-secondary text-white p-4 rounded-lg text-center">Export CSV</a>
<a href="{{ url_for('reports.export_form') }}" class="bg-secondary text-white p-4 rounded-lg text-center hover:bg-indigo-700 transition">
<i class="fas fa-file-csv mr-2"></i>Export CSV
</a>
<a href="{{ url_for('reports.export_excel') }}" class="bg-green-600 text-white p-4 rounded-lg text-center hover:bg-green-700">
<i class="fas fa-file-excel mr-2"></i>Export Excel
</a>

View File

@@ -0,0 +1,365 @@
# Favorite Projects Feature
## Overview
The Favorite Projects feature allows users to mark frequently used projects as favorites for quick access. This feature enhances user productivity by providing easy access to the projects they work with most often.
## Features
- **Star Icon**: Each project in the project list has a star icon that can be clicked to favorite/unfavorite
- **Quick Filter**: Filter to show only favorite projects
- **Per-User**: Each user has their own set of favorite projects
- **Real-time Updates**: Favorite status updates immediately via AJAX
- **Status Awareness**: Favorites work with all project statuses (active, inactive, archived)
## User Guide
### Marking a Project as Favorite
1. Navigate to the Projects list page (`/projects`)
2. Find the project you want to favorite
3. Click the star icon (☆) next to the project name
4. The star will turn yellow/gold (★) indicating it's now a favorite
### Removing a Project from Favorites
1. Navigate to the Projects list page
2. Find the favorited project (marked with a gold star ★)
3. Click the star icon
4. The star will become hollow (☆) indicating it's no longer a favorite
### Filtering by Favorites
1. Navigate to the Projects list page
2. In the filters section, find the "Filter" dropdown
3. Select "Favorites Only"
4. Click "Filter"
5. The list will show only your favorite projects
### Combining Filters
You can combine the favorites filter with other filters:
- **Status Filter**: Show only active favorites, archived favorites, etc.
- **Client Filter**: Show favorites for a specific client
- **Search**: Search within your favorite projects
## Technical Implementation
### Database Schema
A new association table `user_favorite_projects` was created with the following structure:
```sql
CREATE TABLE user_favorite_projects (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
UNIQUE (user_id, project_id)
);
CREATE INDEX ix_user_favorite_projects_user_id ON user_favorite_projects(user_id);
CREATE INDEX ix_user_favorite_projects_project_id ON user_favorite_projects(project_id);
```
### Model Relationships
#### User Model
New methods added to `app/models/user.py`:
- `add_favorite_project(project)`: Add a project to favorites
- `remove_favorite_project(project)`: Remove a project from favorites
- `is_project_favorite(project)`: Check if a project is favorited
- `get_favorite_projects(status='active')`: Get list of favorite projects
New relationship:
```python
favorite_projects = db.relationship('Project',
secondary='user_favorite_projects',
lazy='dynamic',
backref=db.backref('favorited_by', lazy='dynamic'))
```
#### Project Model
New method added to `app/models/project.py`:
- `is_favorited_by(user)`: Check if project is favorited by a specific user
Updated method:
- `to_dict(user=None)`: Now includes `is_favorite` field when user is provided
### API Endpoints
#### POST /projects/<project_id>/favorite
Mark a project as favorite.
**Authentication**: Required
**Method**: POST
**Parameters**: None
**Returns**: JSON response
**Example Request:**
```bash
curl -X POST https://timetracker.example.com/projects/123/favorite \
-H "X-Requested-With: XMLHttpRequest" \
-H "Cookie: session=..."
```
**Example Response (Success):**
```json
{
"success": true,
"message": "Project added to favorites"
}
```
**Example Response (Error):**
```json
{
"success": false,
"message": "Project is already in favorites"
}
```
#### POST /projects/<project_id>/unfavorite
Remove a project from favorites.
**Authentication**: Required
**Method**: POST
**Parameters**: None
**Returns**: JSON response
**Example Request:**
```bash
curl -X POST https://timetracker.example.com/projects/123/unfavorite \
-H "X-Requested-With: XMLHttpRequest" \
-H "Cookie: session=..."
```
**Example Response (Success):**
```json
{
"success": true,
"message": "Project removed from favorites"
}
```
### Routes
#### GET /projects?favorites=true
List projects filtered by favorites.
**Authentication**: Required
**Method**: GET
**Parameters**:
- `favorites`: "true" to show only favorites
- `status`: Filter by status (active/inactive/archived)
- `client`: Filter by client name
- `search`: Search in project name/description
**Example:**
```
GET /projects?favorites=true&status=active
```
### Frontend Implementation
#### JavaScript
The `toggleFavorite(projectId, button)` function handles the star icon clicks:
1. Performs optimistic UI update (changes star immediately)
2. Sends AJAX POST request to favorite/unfavorite endpoint
3. Reverts UI if request fails
4. Shows success/error message
#### UI Components
- **Star Icon**: FontAwesome icons (`fas fa-star` for favorited, `far fa-star` for not favorited)
- **Color Coding**: Yellow/gold for favorited, muted gray for not favorited
- **Filter Dropdown**: Added "Favorites Only" option to the filters form
## Migration
### Running the Migration
To add the favorite projects table to an existing database:
```bash
# Using Alembic
alembic upgrade head
# Or using the migration management script
python migrations/manage_migrations.py upgrade
```
### Rollback
To rollback the favorite projects feature:
```bash
alembic downgrade -1
```
## Activity Logging
The following activities are logged:
- `project.favorited`: When a user adds a project to favorites
- `project.unfavorited`: When a user removes a project from favorites
These activities can be viewed in:
- User activity logs
- System audit trail
- Analytics dashboards (if enabled)
## Testing
Comprehensive test coverage is provided in `tests/test_favorite_projects.py`:
### Test Categories
1. **Model Tests**: Testing the `UserFavoriteProject` model
2. **Method Tests**: Testing User and Project model methods
3. **Route Tests**: Testing API endpoints
4. **Filtering Tests**: Testing the favorites filter
5. **Relationship Tests**: Testing cascade delete behavior
6. **Smoke Tests**: End-to-end workflow tests
### Running Tests
```bash
# Run all favorite projects tests
pytest tests/test_favorite_projects.py -v
# Run specific test class
pytest tests/test_favorite_projects.py::TestUserFavoriteProjectModel -v
# Run with coverage
pytest tests/test_favorite_projects.py --cov=app.models --cov=app.routes -v
```
## Performance Considerations
### Database Indexes
The feature includes indexes on both `user_id` and `project_id` columns in the `user_favorite_projects` table for optimal query performance.
### Query Optimization
- Favorites are loaded once per page load and stored in a set for O(1) lookup
- The favorites filter uses an efficient JOIN query
- Lazy loading is used for relationships to avoid N+1 queries
### Scalability
The feature is designed to scale:
- Indexes ensure fast lookups even with thousands of projects
- Per-user favorites don't impact other users
- AJAX requests are lightweight (no page reloads)
## Security Considerations
- **Authentication Required**: All favorite endpoints require user login
- **User Isolation**: Users can only manage their own favorites
- **CSRF Protection**: All POST requests use CSRF tokens
- **Input Validation**: Project IDs are validated before database operations
- **Cascade Delete**: Favorites are automatically cleaned up when users/projects are deleted
## Browser Compatibility
The feature works in all modern browsers:
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
Required browser features:
- Fetch API for AJAX requests
- ES6 JavaScript (arrow functions, const/let)
- CSS3 for animations
## Future Enhancements
Potential improvements for future versions:
1. **Favorite Count Badge**: Show number of favorites in summary cards
2. **Recently Used**: Track and show recently accessed projects
3. **Favorite Ordering**: Allow users to reorder their favorites
4. **Quick Access Menu**: Add favorites to navigation menu
5. **Keyboard Shortcuts**: Add keyboard shortcut to favorite/unfavorite
6. **Bulk Favorite**: Select and favorite multiple projects at once
7. **Favorites Dashboard**: Dedicated dashboard showing favorite project metrics
8. **Export Favorites**: Export list of favorite projects
9. **Favorite Teams**: Share favorite project lists with team members
10. **Smart Favorites**: Auto-suggest favorites based on usage patterns
## Troubleshooting
### Star Icon Not Appearing
**Symptom**: Star icon column is missing in project list
**Solution**:
- Clear browser cache and reload
- Verify template file is up to date
- Check browser console for JavaScript errors
### Favorite Not Saving
**Symptom**: Clicking star doesn't persist the favorite
**Solution**:
- Check browser console for network errors
- Verify CSRF token is present in page
- Check database migration was applied
- Review server logs for errors
### Migration Fails
**Symptom**: Migration script fails to create table
**Solution**:
- Check database user has CREATE TABLE permissions
- Verify Alembic is up to date
- Check for conflicting table names
- Review migration logs for specific errors
## Support
For issues or questions about this feature:
1. Check the [FAQ](../README.md#faq) section
2. Review the [test cases](../../tests/test_favorite_projects.py) for usage examples
3. Check [GitHub Issues](https://github.com/yourusername/TimeTracker/issues)
4. Contact the development team
## Changelog
### Version 1.0.0 (2025-10-23)
**Added:**
- Initial implementation of favorite projects feature
- Star icon for each project in project list
- Favorites filter in projects page
- User model methods for managing favorites
- Project model methods for checking favorite status
- API endpoints for favorite/unfavorite actions
- Comprehensive test coverage
- Documentation
**Database:**
- Added `user_favorite_projects` table
- Migration script: `023_add_user_favorite_projects.py`
**Security:**
- CSRF protection on all favorite endpoints
- User authentication required
- Per-user favorite isolation

View File

@@ -60,3 +60,7 @@
{"asctime": "2025-10-23 20:18:13,808", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "01a0cac0-5466-4e22-8f05-46352eeafb55", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": null}
{"asctime": "2025-10-23 20:18:15,922", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "5762bce5-cb10-4b59-a16d-aa40af773ec5", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": null}
{"asctime": "2025-10-23 20:18:18,704", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "c19ff3aa-1113-4e2c-81ad-72abf5cbc736", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": null}
{"asctime": "2025-10-23 20:43:18,241", "levelname": "INFO", "name": "timetracker", "message": "project.favorited", "taskName": null, "request_id": "d87e4ea4-4219-4edb-99d4-81829e4c157d", "event": "project.favorited", "user_id": 1, "project_id": 1}
{"asctime": "2025-10-23 20:43:20,050", "levelname": "INFO", "name": "timetracker", "message": "project.unfavorited", "taskName": null, "request_id": "8a0369d4-a457-4bee-bbe9-a21ef7f00056", "event": "project.unfavorited", "user_id": 1, "project_id": 1}
{"asctime": "2025-10-23 20:44:25,411", "levelname": "INFO", "name": "timetracker", "message": "project.favorited", "taskName": null, "request_id": "a64b6ad2-badd-4879-bdc7-ae8b0e94fe3d", "event": "project.favorited", "user_id": 1, "project_id": 1}
{"asctime": "2025-10-23 20:44:26,386", "levelname": "INFO", "name": "timetracker", "message": "project.unfavorited", "taskName": null, "request_id": "73d9bd58-61e5-431d-9963-6a57e2b63e61", "event": "project.unfavorited", "user_id": 1, "project_id": 1}

View File

@@ -0,0 +1,65 @@
"""Add user favorite projects functionality
Revision ID: 024
Revises: 023
Create Date: 2025-10-23 00:00:00
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = '024'
down_revision = '023'
branch_labels = None
depends_on = None
def upgrade():
"""Create user_favorite_projects association table"""
bind = op.get_bind()
dialect_name = bind.dialect.name if bind else 'generic'
# Create the user_favorite_projects table
try:
op.create_table(
'user_favorite_projects',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'project_id', name='uq_user_project_favorite')
)
# Create indexes for faster lookups
op.create_index('ix_user_favorite_projects_user_id', 'user_favorite_projects', ['user_id'])
op.create_index('ix_user_favorite_projects_project_id', 'user_favorite_projects', ['project_id'])
print("✓ Created user_favorite_projects table")
except Exception as e:
print(f"⚠ Warning creating user_favorite_projects table: {e}")
def downgrade():
"""Drop user_favorite_projects association table"""
try:
op.drop_index('ix_user_favorite_projects_project_id', table_name='user_favorite_projects')
except Exception:
pass
try:
op.drop_index('ix_user_favorite_projects_user_id', table_name='user_favorite_projects')
except Exception:
pass
try:
op.drop_table('user_favorite_projects')
print("✓ Dropped user_favorite_projects table")
except Exception as e:
print(f"⚠ Warning dropping user_favorite_projects table: {e}")

View File

@@ -1,78 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('New User') }} - {{ 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('admin.admin_dashboard') }}">{{ _('Admin') }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('admin.list_users') }}">{{ _('Users') }}</a></li>
<li class="breadcrumb-item active">{{ _('New') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-user-plus text-primary"></i> {{ _('New User') }}
</h1>
</div>
<div>
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Users') }}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-id-card"></i> {{ _('User Information') }}
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.create_user') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="username" class="form-label">{{ _('Username') }} *</label>
<input type="text" class="form-control" id="username" name="username" required value="{{ request.form.get('username','') }}" placeholder="{{ _('Enter username') }}">
<div class="form-text">{{ _('Lowercase; must be unique.') }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="role" class="form-label">{{ _('Role') }} *</label>
<select class="form-select" id="role" name="role" required>
{% set current_role = request.form.get('role','user') %}
<option value="user" {% if current_role == 'user' %}selected{% endif %}>{{ _('User') }}</option>
<option value="admin" {% if current_role == 'admin' %}selected{% endif %}>{{ _('Admin') }}</option>
</select>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{{ _('Create User') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,276 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin Dashboard') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-primary me-2">
<i class="fas fa-info-circle me-2"></i>{{ _('System Info') }}
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-primary me-2">
<i class="fas fa-download me-2"></i>{{ _('Create Backup') }}
</a>
<a href="{{ url_for('admin.restore') }}" class="btn btn-outline-primary me-2">
<i class="fas fa-undo-alt me-2"></i>{{ _('Restore') }}
</a>
<a href="{{ url_for('admin.oidc_debug') }}" class="btn btn-outline-warning">
<i class="fas fa-shield-alt me-2"></i>{{ _('OIDC Debug') }}
</a>
{% endset %}
{{ page_header('fas fa-cogs', _('Admin Dashboard'), _('Manage users, system settings, and core operations at a glance.'), actions) }}
</div>
</div>
<!-- System Statistics -->
<div class="row section-spacing">
<div class="col-md-3 col-sm-6 mb-3">
{% from "_components.html" import summary_card %}
{{ summary_card('fas fa-users', 'primary', 'Total Users', stats.total_users) }}
</div>
<div class="col-md-3 col-sm-6 mb-3">
{{ summary_card('fas fa-project-diagram', 'success', 'Total Projects', stats.total_projects) }}
</div>
<div class="col-md-3 col-sm-6 mb-3">
{{ summary_card('fas fa-clock', 'info', 'Time Entries', stats.total_entries) }}
</div>
<div class="col-md-3 col-sm-6 mb-3">
{{ summary_card('fas fa-stopwatch', 'warning', 'Total Hours', "%.1f"|format(stats.total_hours) ~ 'h') }}
</div>
</div>
<!-- OIDC Authentication Status -->
<div class="row section-spacing">
<div class="col-12 mb-4">
<div class="card border-{% if oidc_enabled %}success{% else %}secondary{% endif %} hover-lift">
<div class="card-header bg-{% if oidc_enabled %}success{% else %}light{% endif %} text-{% if oidc_enabled %}white{% else %}dark{% endif %}">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-shield-alt me-2"></i>{{ _('OIDC Authentication Status') }}
</h5>
<a href="{{ url_for('admin.oidc_debug') }}" class="btn btn-sm {% if oidc_enabled %}btn-light{% else %}btn-outline-secondary{% endif %}">
<i class="fas fa-tools me-2"></i>{{ _('Debug Dashboard') }}
</a>
</div>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3 text-center mb-3 mb-md-0">
<div class="d-flex flex-column align-items-center">
{% if oidc_enabled %}
<div class="mb-2">
<i class="fas fa-check-circle fa-3x text-success"></i>
</div>
<h4 class="mb-0 text-success">{{ _('ENABLED') }}</h4>
{% else %}
<div class="mb-2">
<i class="fas fa-times-circle fa-3x text-secondary"></i>
</div>
<h4 class="mb-0 text-secondary">{{ _('DISABLED') }}</h4>
{% endif %}
<small class="text-muted mt-1">{{ _('OIDC SSO') }}</small>
</div>
</div>
<div class="col-md-6">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th width="40%">{{ _('Auth Method') }}:</th>
<td>
<code class="{% if oidc_enabled %}text-success{% else %}text-muted{% endif %}">
{{ oidc_auth_method|upper }}
</code>
</td>
</tr>
<tr>
<th>{{ _('Configuration') }}:</th>
<td>
{% if oidc_configured %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>{{ _('Complete') }}
</span>
{% elif oidc_enabled %}
<span class="badge bg-warning">
<i class="fas fa-exclamation-triangle me-1"></i>{{ _('Incomplete') }}
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-minus me-1"></i>{{ _('Not configured') }}
</span>
{% endif %}
</td>
</tr>
<tr>
<th>{{ _('OIDC Users') }}:</th>
<td>
<strong>{{ oidc_users_count }}</strong> {{ _('user(s)') }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-3 text-center">
<div class="d-grid gap-2">
{% if oidc_enabled %}
<a href="{{ url_for('admin.oidc_test') }}" class="btn btn-success btn-sm">
<i class="fas fa-vial me-2"></i>{{ _('Test Config') }}
</a>
<a href="{{ url_for('admin.oidc_debug') }}" class="btn btn-outline-success btn-sm">
<i class="fas fa-cog me-2"></i>{{ _('View Details') }}
</a>
{% else %}
<small class="text-muted">
{{ _('Set AUTH_METHOD=oidc to enable') }}
</small>
<a href="{{ url_for('admin.oidc_debug') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-book me-2"></i>{{ _('Setup Guide') }}
</a>
{% endif %}
</div>
</div>
</div>
{% if oidc_enabled and not oidc_configured %}
<div class="alert alert-warning mt-3 mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Configuration Incomplete:') }}</strong>
{{ _('OIDC is enabled but missing required environment variables. Check the') }}
<a href="{{ url_for('admin.oidc_debug') }}" class="alert-link">{{ _('Debug Dashboard') }}</a>
{{ _('for details.') }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row section-spacing">
<div class="col-md-6 mb-4">
<div class="card hover-lift">
<div class="card-header">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-user-cog me-2 text-primary"></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-primary">
<i class="fas fa-users me-2"></i>{{ _('Manage Users') }}
</a>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-success">
<i class="fas fa-user-plus me-2"></i>{{ _('Create New User') }}
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card hover-lift">
<div class="card-header">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-cog me-2 text-primary"></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-primary">
<i class="fas fa-sliders-h me-2"></i>{{ _('Configure Settings') }}
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-primary">
<i class="fas fa-download me-2"></i>{{ _('Create Backup') }}
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-8 mb-4">
<div class="card hover-lift">
<div class="card-header">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-history me-2 text-primary"></i>{{ _('Recent Activity') }}
</h5>
</div>
<div class="card-body p-0">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<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.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>{{ entry.duration_formatted }}</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 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.oidc_debug') }}" class="btn btn-outline-warning">
<i class="fas fa-shield-alt"></i> {{ _('OIDC Debug') }}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,595 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Settings') }} - {{ 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">
<h1 class="h3 mb-0">
<i class="fas fa-sliders-h text-primary"></i> {{ _('System Settings') }}
</h1>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn-header btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>{{ _('Back to Dashboard') }}
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('Configuration') }}</h5>
<a href="{{ url_for('admin.pdf_layout') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-file-pdf me-2"></i>{{ _('Edit PDF Layout') }}
</a>
</div>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="timezone">{{ _('Timezone') }}</label>
<select class="form-select" id="timezone" name="timezone">
<optgroup label="UTC">
<option value="UTC" {% if settings and settings.timezone == 'UTC' %}selected{% endif %}>UTC (UTC+0)</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London" {% if settings and settings.timezone == 'Europe/London' %}selected{% endif %}>Europe/London (UTC+0/+1)</option>
<option value="Europe/Paris" {% if settings and settings.timezone == 'Europe/Paris' %}selected{% endif %}>Europe/Paris (UTC+1/+2)</option>
<option value="Europe/Berlin" {% if settings and settings.timezone == 'Europe/Berlin' %}selected{% endif %}>Europe/Berlin (UTC+1/+2)</option>
<option value="Europe/Rome" {% if (settings and settings.timezone == 'Europe/Rome') or not settings %}selected{% endif %}>Europe/Rome (UTC+1/+2)</option>
<option value="Europe/Madrid" {% if settings and settings.timezone == 'Europe/Madrid' %}selected{% endif %}>Europe/Madrid (UTC+1/+2)</option>
<option value="Europe/Amsterdam" {% if settings and settings.timezone == 'Europe/Amsterdam' %}selected{% endif %}>Europe/Amsterdam (UTC+1/+2)</option>
<option value="Europe/Brussels" {% if settings and settings.timezone == 'Europe/Brussels' %}selected{% endif %}>Europe/Brussels (UTC+1/+2)</option>
<option value="Europe/Vienna" {% if settings and settings.timezone == 'Europe/Vienna' %}selected{% endif %}>Europe/Vienna (UTC+1/+2)</option>
<option value="Europe/Zurich" {% if settings and settings.timezone == 'Europe/Zurich' %}selected{% endif %}>Europe/Zurich (UTC+1/+2)</option>
<option value="Europe/Prague" {% if settings and settings.timezone == 'Europe/Prague' %}selected{% endif %}>Europe/Prague (UTC+1/+2)</option>
<option value="Europe/Warsaw" {% if settings and settings.timezone == 'Europe/Warsaw' %}selected{% endif %}>Europe/Warsaw (UTC+1/+2)</option>
<option value="Europe/Budapest" {% if settings and settings.timezone == 'Europe/Budapest' %}selected{% endif %}>Europe/Budapest (UTC+1/+2)</option>
<option value="Europe/Stockholm" {% if settings and settings.timezone == 'Europe/Stockholm' %}selected{% endif %}>Europe/Stockholm (UTC+1/+2)</option>
<option value="Europe/Oslo" {% if settings and settings.timezone == 'Europe/Oslo' %}selected{% endif %}>Europe/Oslo (UTC+1/+2)</option>
<option value="Europe/Copenhagen" {% if settings and settings.timezone == 'Europe/Copenhagen' %}selected{% endif %}>Europe/Copenhagen (UTC+1/+2)</option>
<option value="Europe/Helsinki" {% if settings and settings.timezone == 'Europe/Helsinki' %}selected{% endif %}>Europe/Helsinki (UTC+2/+3)</option>
<option value="Europe/Athens" {% if settings and settings.timezone == 'Europe/Athens' %}selected{% endif %}>Europe/Athens (UTC+2/+3)</option>
<option value="Europe/Istanbul" {% if settings and settings.timezone == 'Europe/Istanbul' %}selected{% endif %}>Europe/Istanbul (UTC+3)</option>
<option value="Europe/Moscow" {% if settings and settings.timezone == 'Europe/Moscow' %}selected{% endif %}>Europe/Moscow (UTC+3)</option>
<option value="Europe/Kiev" {% if settings and settings.timezone == 'Europe/Kiev' %}selected{% endif %}>Europe/Kiev (UTC+2/+3)</option>
</optgroup>
<optgroup label="North America">
<option value="America/New_York" {% if settings and settings.timezone == 'America/New_York' %}selected{% endif %}>America/New_York (UTC-5/-4)</option>
<option value="America/Chicago" {% if settings and settings.timezone == 'America/Chicago' %}selected{% endif %}>America/Chicago (UTC-6/-5)</option>
<option value="America/Denver" {% if settings and settings.timezone == 'America/Denver' %}selected{% endif %}>America/Denver (UTC-7/-6)</option>
<option value="America/Los_Angeles" {% if settings and settings.timezone == 'America/Los_Angeles' %}selected{% endif %}>America/Los_Angeles (UTC-8/-7)</option>
<option value="America/Toronto" {% if settings and settings.timezone == 'America/Toronto' %}selected{% endif %}>America/Toronto (UTC-5/-4)</option>
<option value="America/Vancouver" {% if settings and settings.timezone == 'America/Vancouver' %}selected{% endif %}>America/Vancouver (UTC-8/-7)</option>
<option value="America/Mexico_City" {% if settings and settings.timezone == 'America/Mexico_City' %}selected{% endif %}>America/Mexico_City (UTC-6/-5)</option>
<option value="America/Phoenix" {% if settings and settings.timezone == 'America/Phoenix' %}selected{% endif %}>America/Phoenix (UTC-7)</option>
<option value="America/Anchorage" {% if settings and settings.timezone == 'America/Anchorage' %}selected{% endif %}>America/Anchorage (UTC-9/-8)</option>
<option value="America/Honolulu" {% if settings and settings.timezone == 'America/Honolulu' %}selected{% endif %}>America/Honolulu (UTC-10)</option>
<option value="America/Sao_Paulo" {% if settings and settings.timezone == 'America/Sao_Paulo' %}selected{% endif %}>America/Sao_Paulo (UTC-3/-2)</option>
<option value="America/Buenos_Aires" {% if settings and settings.timezone == 'America/Buenos_Aires' %}selected{% endif %}>America/Buenos_Aires (UTC-3)</option>
<option value="America/Santiago" {% if settings and settings.timezone == 'America/Santiago' %}selected{% endif %}>America/Santiago (UTC-3/-4)</option>
<option value="America/Lima" {% if settings and settings.timezone == 'America/Lima' %}selected{% endif %}>America/Lima (UTC-5)</option>
<option value="America/Bogota" {% if settings and settings.timezone == 'America/Bogota' %}selected{% endif %}>America/Bogota (UTC-5)</option>
<option value="America/Caracas" {% if settings and settings.timezone == 'America/Caracas' %}selected{% endif %}>America/Caracas (UTC-4)</option>
</optgroup>
<optgroup label="Asia">
<option value="Asia/Tokyo" {% if settings and settings.timezone == 'Asia/Tokyo' %}selected{% endif %}>Asia/Tokyo (UTC+9)</option>
<option value="Asia/Shanghai" {% if settings and settings.timezone == 'Asia/Shanghai' %}selected{% endif %}>Asia/Shanghai (UTC+8)</option>
<option value="Asia/Seoul" {% if settings and settings.timezone == 'Asia/Seoul' %}selected{% endif %}>Asia/Seoul (UTC+9)</option>
<option value="Asia/Hong_Kong" {% if settings and settings.timezone == 'Asia/Hong_Kong' %}selected{% endif %}>Asia/Hong_Kong (UTC+8)</option>
<option value="Asia/Singapore" {% if settings and settings.timezone == 'Asia/Singapore' %}selected{% endif %}>Asia/Singapore (UTC+8)</option>
<option value="Asia/Bangkok" {% if settings and settings.timezone == 'Asia/Bangkok' %}selected{% endif %}>Asia/Bangkok (UTC+7)</option>
<option value="Asia/Ho_Chi_Minh" {% if settings and settings.timezone == 'Asia/Ho_Chi_Minh' %}selected{% endif %}>Asia/Ho_Chi_Minh (UTC+7)</option>
<option value="Asia/Jakarta" {% if settings and settings.timezone == 'Asia/Jakarta' %}selected{% endif %}>Asia/Jakarta (UTC+7)</option>
<option value="Asia/Manila" {% if settings and settings.timezone == 'Asia/Manila' %}selected{% endif %}>Asia/Manila (UTC+8)</option>
<option value="Asia/Kolkata" {% if settings and settings.timezone == 'Asia/Kolkata' %}selected{% endif %}>Asia/Kolkata (UTC+5:30)</option>
<option value="Asia/Dhaka" {% if settings and settings.timezone == 'Asia/Dhaka' %}selected{% endif %}>Asia/Dhaka (UTC+6)</option>
<option value="Asia/Kathmandu" {% if settings and settings.timezone == 'Asia/Kathmandu' %}selected{% endif %}>Asia/Kathmandu (UTC+5:45)</option>
<option value="Asia/Tashkent" {% if settings and settings.timezone == 'Asia/Tashkent' %}selected{% endif %}>Asia/Tashkent (UTC+5)</option>
<option value="Asia/Dubai" {% if settings and settings.timezone == 'Asia/Dubai' %}selected{% endif %}>Asia/Dubai (UTC+4)</option>
<option value="Asia/Tehran" {% if settings and settings.timezone == 'Asia/Tehran' %}selected{% endif %}>Asia/Tehran (UTC+3:30/+4:30)</option>
<option value="Asia/Jerusalem" {% if settings and settings.timezone == 'Asia/Jerusalem' %}selected{% endif %}>Asia/Jerusalem (UTC+2/+3)</option>
<option value="Asia/Riyadh" {% if settings and settings.timezone == 'Asia/Riyadh' %}selected{% endif %}>Asia/Riyadh (UTC+3)</option>
<option value="Asia/Baghdad" {% if settings and settings.timezone == 'Asia/Baghdad' %}selected{% endif %}>Asia/Baghdad (UTC+3)</option>
<option value="Asia/Kabul" {% if settings and settings.timezone == 'Asia/Kabul' %}selected{% endif %}>Asia/Kabul (UTC+4:30)</option>
<option value="Asia/Almaty" {% if settings and settings.timezone == 'Asia/Almaty' %}selected{% endif %}>Asia/Almaty (UTC+6)</option>
<option value="Asia/Novosibirsk" {% if settings and settings.timezone == 'Asia/Novosibirsk' %}selected{% endif %}>Asia/Novosibirsk (UTC+7)</option>
<option value="Asia/Vladivostok" {% if settings and settings.timezone == 'Asia/Vladivostok' %}selected{% endif %}>Asia/Vladivostok (UTC+10)</option>
</optgroup>
<optgroup label="Australia & Pacific">
<option value="Australia/Sydney" {% if settings and settings.timezone == 'Australia/Sydney' %}selected{% endif %}>Australia/Sydney (UTC+10/+11)</option>
<option value="Australia/Melbourne" {% if settings and settings.timezone == 'Australia/Melbourne' %}selected{% endif %}>Australia/Melbourne (UTC+10/+11)</option>
<option value="Australia/Brisbane" {% if settings and settings.timezone == 'Australia/Brisbane' %}selected{% endif %}>Australia/Brisbane (UTC+10)</option>
<option value="Australia/Perth" {% if settings and settings.timezone == 'Australia/Perth' %}selected{% endif %}>Australia/Perth (UTC+8)</option>
<option value="Australia/Adelaide" {% if settings and settings.timezone == 'Australia/Adelaide' %}selected{% endif %}>Australia/Adelaide (UTC+9:30/+10:30)</option>
<option value="Australia/Darwin" {% if settings and settings.timezone == 'Australia/Darwin' %}selected{% endif %}>Australia/Darwin (UTC+9:30)</option>
<option value="Pacific/Auckland" {% if settings and settings.timezone == 'Pacific/Auckland' %}selected{% endif %}>Pacific/Auckland (UTC+12/+13)</option>
<option value="Pacific/Fiji" {% if settings and settings.timezone == 'Pacific/Fiji' %}selected{% endif %}>Pacific/Fiji (UTC+12)</option>
<option value="Pacific/Guam" {% if settings and settings.timezone == 'Pacific/Guam' %}selected{% endif %}>Pacific/Guam (UTC+10)</option>
<option value="Pacific/Honolulu" {% if settings and settings.timezone == 'Pacific/Honolulu' %}selected{% endif %}>Pacific/Honolulu (UTC-10)</option>
<option value="Pacific/Tahiti" {% if settings and settings.timezone == 'Pacific/Tahiti' %}selected{% endif %}>Pacific/Tahiti (UTC-10)</option>
</optgroup>
<optgroup label="Africa">
<option value="Africa/Cairo" {% if settings and settings.timezone == 'Africa/Cairo' %}selected{% endif %}>Africa/Cairo (UTC+2)</option>
<option value="Africa/Johannesburg" {% if settings and settings.timezone == 'Africa/Johannesburg' %}selected{% endif %}>Africa/Johannesburg (UTC+2)</option>
<option value="Africa/Lagos" {% if settings and settings.timezone == 'Africa/Lagos' %}selected{% endif %}>Africa/Lagos (UTC+1)</option>
<option value="Africa/Nairobi" {% if settings and settings.timezone == 'Africa/Nairobi' %}selected{% endif %}>Africa/Nairobi (UTC+3)</option>
<option value="Africa/Casablanca" {% if settings and settings.timezone == 'Africa/Casablanca' %}selected{% endif %}>Africa/Casablanca (UTC+0/+1)</option>
<option value="Africa/Algiers" {% if settings and settings.timezone == 'Africa/Algiers' %}selected{% endif %}>Africa/Algiers (UTC+1)</option>
<option value="Africa/Tunis" {% if settings and settings.timezone == 'Africa/Tunis' %}selected{% endif %}>Africa/Tunis (UTC+1)</option>
<option value="Africa/Dar_es_Salaam" {% if settings and settings.timezone == 'Africa/Dar_es_Salaam' %}selected{% endif %}>Africa/Dar_es_Salaam (UTC+3)</option>
<option value="Africa/Addis_Ababa" {% if settings and settings.timezone == 'Africa/Addis_Ababa' %}selected{% endif %}>Africa/Addis_Ababa (UTC+3)</option>
<option value="Africa/Khartoum" {% if settings and settings.timezone == 'Africa/Khartoum' %}selected{% endif %}>Africa/Khartoum (UTC+2)</option>
<option value="Africa/Luanda" {% if settings and settings.timezone == 'Africa/Luanda' %}selected{% endif %}>Africa/Luanda (UTC+1)</option>
<option value="Africa/Kinshasa" {% if settings and settings.timezone == 'Africa/Kinshasa' %}selected{% endif %}>Africa/Kinshasa (UTC+1)</option>
<option value="Africa/Harare" {% if settings and settings.timezone == 'Africa/Harare' %}selected{% endif %}>Africa/Harare (UTC+2)</option>
</optgroup>
<optgroup label="Atlantic & Indian Ocean">
<option value="Atlantic/Reykjavik" {% if settings and settings.timezone == 'Atlantic/Reykjavik' %}selected{% endif %}>Atlantic/Reykjavik (UTC+0)</option>
<option value="Atlantic/Azores" {% if settings and settings.timezone == 'Atlantic/Azores' %}selected{% endif %}>Atlantic/Azores (UTC-1/+0)</option>
<option value="Atlantic/Canary" {% if settings and settings.timezone == 'Atlantic/Canary' %}selected{% endif %}>Atlantic/Canary (UTC+0/+1)</option>
<option value="Atlantic/Cape_Verde" {% if settings and settings.timezone == 'Atlantic/Cape_Verde' %}selected{% endif %}>Atlantic/Cape_Verde (UTC-1)</option>
<option value="Indian/Mauritius" {% if settings and settings.timezone == 'Indian/Mauritius' %}selected{% endif %}>Indian/Mauritius (UTC+4)</option>
<option value="Indian/Reunion" {% if settings and settings.timezone == 'Indian/Reunion' %}selected{% endif %}>Indian/Reunion (UTC+4)</option>
<option value="Indian/Maldives" {% if settings and settings.timezone == 'Indian/Maldives' %}selected{% endif %}>Indian/Maldives (UTC+5)</option>
<option value="Indian/Chagos" {% if settings and settings.timezone == 'Indian/Chagos' %}selected{% endif %}>Indian/Chagos (UTC+6)</option>
</optgroup>
<optgroup label="Arctic & Antarctic">
<option value="Arctic/Longyearbyen" {% if settings and settings.timezone == 'Arctic/Longyearbyen' %}selected{% endif %}>Arctic/Longyearbyen (UTC+1/+2)</option>
<option value="Antarctica/McMurdo" {% if settings and settings.timezone == 'Antarctica/McMurdo' %}selected{% endif %}>Antarctica/McMurdo (UTC+12/+13)</option>
<option value="Antarctica/Palmer" {% if settings and settings.timezone == 'Antarctica/Palmer' %}selected{% endif %}>Antarctica/Palmer (UTC-3)</option>
</optgroup>
</select>
<small class="form-text text-muted">{{ _('Select your local timezone for proper time display. Times shown include DST adjustments.') }}</small>
</div>
<div class="col-md-6">
<label class="form-label" for="currency">{{ _('Currency') }}</label>
<input type="text" class="form-control" id="currency" name="currency" value="{{ settings.currency if settings else 'EUR' }}" placeholder="e.g. EUR">
</div>
<div class="col-md-6">
<label class="form-label" for="rounding_minutes">{{ _('Rounding (minutes)') }}</label>
<input type="number" min="0" step="1" class="form-control" id="rounding_minutes" name="rounding_minutes" value="{{ settings.rounding_minutes if settings else 1 }}">
</div>
<div class="col-md-6">
<label class="form-label" for="idle_timeout_minutes">{{ _('Idle Timeout (minutes)') }}</label>
<input type="number" min="0" step="1" class="form-control" id="idle_timeout_minutes" name="idle_timeout_minutes" value="{{ settings.idle_timeout_minutes if settings else 30 }}">
</div>
<div class="col-md-6">
<label class="form-label" for="backup_retention_days">{{ _('Backup Retention (days)') }}</label>
<input type="number" min="0" step="1" class="form-control" id="backup_retention_days" name="backup_retention_days" value="{{ settings.backup_retention_days if settings else 30 }}">
</div>
<div class="col-md-6">
<label class="form-label" for="backup_time">{{ _('Backup Time (HH:MM)') }}</label>
<input type="time" class="form-control" id="backup_time" name="backup_time" value="{{ settings.backup_time if settings else '02:00' }}">
</div>
<div class="col-md-6">
<label class="form-label" for="export_delimiter">{{ _('Export Delimiter') }}</label>
<select class="form-select" id="export_delimiter" name="export_delimiter">
{% set delim = (settings.export_delimiter if settings else ',') %}
<option value="," {% if delim == ',' %}selected{% endif %}>{{ _(', (comma)') }}</option>
<option value=";" {% if delim == ';' %}selected{% endif %}>{{ _('; (semicolon)') }}</option>
<option value="\t" {% if delim == '\t' %}selected{% endif %}>{{ _('Tab') }}</option>
<option value="|" {% if delim == '|' %}selected{% endif %}>{{ _('| (pipe)') }}</option>
</select>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check me-4">
<input class="form-check-input" type="checkbox" id="single_active_timer" name="single_active_timer" {% if settings and settings.single_active_timer %}checked{% endif %}>
<label class="form-check-label" for="single_active_timer">{{ _('Single Active Timer') }}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allow_self_register" name="allow_self_register" {% if settings and settings.allow_self_register %}checked{% endif %}>
<label class="form-check-label" for="allow_self_register">{{ _('Allow Self Register') }}</label>
</div>
</div>
<div class="col-12 mt-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
</div>
<!-- Company Branding Section -->
<div class="row g-3 mt-4">
<div class="col-12">
<h5 class="text-primary border-bottom pb-2">
<i class="fas fa-building me-2"></i>Company Branding (for Invoices)
</h5>
</div>
<div class="col-md-6">
<label class="form-label" for="company_name">{{ _('Company Name') }}</label>
<input type="text" class="form-control" id="company_name" name="company_name"
value="{{ settings.company_name if settings else 'Your Company Name' }}" required>
</div>
<div class="col-md-6">
<label class="form-label" for="company_email">{{ _('Company Email') }}</label>
<input type="email" class="form-control" id="company_email" name="company_email"
value="{{ settings.company_email if settings else 'info@yourcompany.com' }}" required>
</div>
<div class="col-md-6">
<label class="form-label" for="company_phone">{{ _('Company Phone') }}</label>
<input type="text" class="form-control" id="company_phone" name="company_phone"
value="{{ settings.company_phone if settings else '+1 (555) 123-4567' }}" required>
</div>
<div class="col-md-6">
<label class="form-label" for="company_website">{{ _('Company Website') }}</label>
<input type="url" class="form-control" id="company_website" name="company_website"
value="{{ settings.company_website if settings else 'www.yourcompany.com' }}" required>
</div>
<div class="col-12">
<label class="form-label" for="company_address">{{ _('Company Address') }}</label>
<textarea class="form-control" id="company_address" name="company_address" rows="3" required>{{ settings.company_address if settings else 'Your Company Address' }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label" for="company_tax_id">{{ _('Tax ID / VAT Number') }}</label>
<input type="text" class="form-control" id="company_tax_id" name="company_tax_id"
value="{{ settings.company_tax_id if settings else '' }}" placeholder="Optional">
</div>
<div class="col-md-6">
<label class="form-label" for="company_logo">{{ _('Company Logo') }}</label>
<div class="logo-upload-section">
{% if settings and settings.has_logo() %}
<div class="current-logo mb-3">
<img src="{{ settings.get_logo_url() }}" alt="{{ _('Current Company Logo') }}"
class="img-thumbnail" style="max-width: 150px; max-height: 150px;">
<div class="mt-2">
<form method="POST" action="{{ url_for('admin.remove_logo') }}"
class="d-inline"
data-confirm="{{ _('Are you sure you want to remove the current logo?') }}"
onsubmit="return confirm(this.getAttribute('data-confirm'))">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash me-1"></i> {{ _('Remove Logo') }}
</button>
</form>
</div>
</div>
{% endif %}
<div class="upload-controls">
<input type="file" class="form-control" id="logo_file"
accept=".png,.jpg,.jpeg,.gif,.webp"
onchange="previewLogo(this)">
<small class="form-text text-muted">
{{ _('Supported formats: PNG, JPG, JPEG, GIF, WEBP (Max size: 5MB)') }}
</small>
<div class="mt-3">
<button type="button" class="btn btn-primary" onclick="uploadLogo()">
<i class="fas fa-upload me-1"></i> {{ _('Upload Logo') }}
</button>
</div>
</div>
<div id="logo-preview" class="mt-3" style="display: none;">
<h6>{{ _('Logo Preview:') }}</h6>
<img id="preview-image" class="img-thumbnail" style="max-width: 150px; max-height: 150px;">
</div>
</div>
</div>
<div class="col-12">
<label class="form-label" for="company_bank_info">{{ _('Bank/Payment Information') }}</label>
<textarea class="form-control" id="company_bank_info" name="company_bank_info" rows="3" placeholder="Bank account details, payment instructions, etc.">{{ settings.company_bank_info if settings else '' }}</textarea>
<small class="form-text text-muted">{{ _('This will appear on invoices for payment instructions') }}</small>
</div>
</div>
<!-- Invoice Defaults Section -->
<div class="row g-3 mt-4">
<div class="col-12">
<h5 class="text-primary border-bottom pb-2">
<i class="fas fa-file-invoice-dollar me-2"></i>{{ _('Invoice Defaults') }}
</h5>
</div>
<div class="col-md-6">
<label class="form-label" for="invoice_prefix">{{ _('Invoice Number Prefix') }}</label>
<input type="text" class="form-control" id="invoice_prefix" name="invoice_prefix"
value="{{ settings.invoice_prefix if settings else 'INV' }}" required>
</div>
<div class="col-md-6">
<label class="form-label" for="invoice_start_number">{{ _('Starting Invoice Number') }}</label>
<input type="number" class="form-control" id="invoice_start_number" name="invoice_start_number"
value="{{ settings.invoice_start_number if settings else 1000 }}" min="1" required>
</div>
<div class="col-12">
<label class="form-label" for="invoice_terms">{{ _('Default Terms & Conditions') }}</label>
<textarea class="form-control" id="invoice_terms" name="invoice_terms" rows="3" required>{{ settings.invoice_terms if settings else 'Payment is due within 30 days of invoice date.' }}</textarea>
</div>
<div class="col-12">
<label class="form-label" for="invoice_notes">{{ _('Default Invoice Notes') }}</label>
<textarea class="form-control" id="invoice_notes" name="invoice_notes" rows="2" required>{{ settings.invoice_notes if settings else 'Thank you for your business!' }}</textarea>
</div>
</div>
<!-- Privacy & Analytics Section -->
<div class="row g-3 mt-4">
<div class="col-12">
<h5 class="text-primary border-bottom pb-2">
<i class="fas fa-shield-alt me-2"></i>{{ _('Privacy & Analytics') }}
</h5>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="allow_analytics" name="allow_analytics"
{% if settings and settings.allow_analytics %}checked{% endif %}>
<label class="form-check-label" for="allow_analytics">
<strong>{{ _('Allow Analytics Information') }}</strong>
</label>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
{{ _('When enabled, basic system information (OS, version, etc.) may be shared for analytics purposes.') }}
<strong>{{ _('Core functionality will continue to work regardless of this setting.') }}</strong>
<br><small class="text-muted">{{ _('This helps improve the application.') }}</small>
</div>
</div>
</div>
</div>
<div class="col-12 mt-3">
<div class="alert alert-info">
<div class="row align-items-center">
<div class="col-md-8">
<i class="fas fa-clock me-2"></i>
<strong>{{ _('Current Time:') }}</strong>
<span id="current-time-display" class="h5 mb-0 ms-2">{{ _('Loading...') }}</span>
</div>
<div class="col-md-4 text-md-end">
<small class="text-muted">
{{ _('in') }} <strong>{{ settings.timezone if settings else 'Europe/Rome' }}</strong> {{ _('timezone') }}
</small>
</div>
</div>
<div class="mt-2">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
{{ _('This time updates every second and shows the current time in your selected timezone') }}
</small>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">
<i class="fas fa-globe me-1"></i>
{{ _('Current offset:') }} <span id="timezone-offset">--</span>
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-info-circle me-1"></i> {{ _('Help') }}</h5>
</div>
<div class="card-body">
<p class="text-muted">{{ _('Configure application-wide settings such as timezone, currency, timer behavior, data export options, and company branding for invoices.') }}</p>
<ul class="text-muted mb-0">
<li>{{ _('Rounding affects how durations are rounded when displayed.') }}</li>
<li>{{ _('Single Active Timer stops any running timer when a new one is started.') }}</li>
<li>{{ _('Self Register allows new usernames to be created on login.') }}</li>
<li>{{ _('Company branding settings are used for PDF invoice generation.') }}</li>
<li>{{ _('Company logos can be uploaded directly through the interface (PNG, JPG, JPEG, GIF, SVG, WEBP formats supported).') }}</li>
<li>{{ _('Analytics setting controls whether system information is shared for analytics purposes.') }}</li>
<li><strong>{{ _('Core functionality will continue to work regardless of the analytics setting.') }}</strong></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script type="application/json" id="i18n-json-admin-settings">
{
"file_size_less_than_5mb": {{ _('File size must be less than 5MB')|tojson }},
"invalid_file_type": {{ _('Invalid file type. Please select a valid image file.')|tojson }},
"select_logo_file": {{ _('Please select a logo file to upload')|tojson }},
"uploading": {{ _('Uploading...')|tojson }},
"logo_upload_failed": {{ _('Logo upload failed. Please try again.')|tojson }},
"no_timezone_selected": {{ _('No timezone selected')|tojson }},
"invalid_timezone": {{ _('Invalid timezone')|tojson }}
}
</script>
<script>
var i18nSettings = (function(){
try { var el = document.getElementById('i18n-json-admin-settings'); return el ? JSON.parse(el.textContent) : {}; }
catch(e) { return {}; }
})();
document.addEventListener('DOMContentLoaded', function() {
const timezoneSelect = document.getElementById('timezone');
const currentTimeDisplay = document.getElementById('current-time-display');
function updateCurrentTime() {
const now = new Date();
const timezone = timezoneSelect.value;
if (!timezone) {
currentTimeDisplay.textContent = (i18nSettings.no_timezone_selected || 'No timezone selected');
document.getElementById('timezone-offset').textContent = '--';
return;
}
try {
// Format time in the selected timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const localTime = formatter.format(now);
currentTimeDisplay.textContent = localTime;
// Calculate and display timezone offset
const utcTime = new Date(now.toLocaleString("en-US", {timeZone: "UTC"}));
const localTimeObj = new Date(now.toLocaleString("en-US", {timeZone: timezone}));
const offset = (localTimeObj - utcTime) / (1000 * 60 * 60);
const offsetText = offset >= 0 ? `UTC+${offset.toFixed(1)}` : `UTC${offset.toFixed(1)}`;
document.getElementById('timezone-offset').textContent = offsetText;
} catch (e) {
currentTimeDisplay.textContent = (i18nSettings.invalid_timezone || 'Invalid timezone');
document.getElementById('timezone-offset').textContent = '--';
}
}
// Update time when timezone changes
timezoneSelect.addEventListener('change', updateCurrentTime);
// Update time every second
setInterval(updateCurrentTime, 1000);
// Initial update with a small delay to ensure DOM is ready
setTimeout(updateCurrentTime, 100);
});
// Logo upload and preview functions
function previewLogo(input) {
const preview = document.getElementById('logo-preview');
const previewImage = document.getElementById('preview-image');
if (input.files && input.files[0]) {
const file = input.files[0];
// Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
alert(i18nSettings.file_size_less_than_5mb || 'File size must be less than 5MB');
input.value = '';
return;
}
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
alert(i18nSettings.invalid_file_type || 'Invalid file type. Please select a valid image file.');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
previewImage.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
preview.style.display = 'none';
}
}
function uploadLogo() {
const fileInput = document.getElementById('logo_file');
const file = fileInput.files[0];
if (!file) {
alert(i18nSettings.select_logo_file || 'Please select a logo file to upload');
return;
}
// Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
alert(i18nSettings.file_size_less_than_5mb || 'File size must be less than 5MB');
return;
}
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
alert(i18nSettings.invalid_file_type || 'Invalid file type. Please select a valid image file.');
return;
}
// Create FormData and submit
const formData = new FormData();
formData.append('logo', file);
// Show loading state
const uploadBtn = document.querySelector('button[onclick="uploadLogo()"]');
const originalText = uploadBtn.innerHTML;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> ' + (i18nSettings.uploading || 'Uploading...');
uploadBtn.disabled = true;
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
fetch('{{ url_for("admin.upload_logo") }}', {
method: 'POST',
headers: csrf ? { 'X-CSRFToken': csrf } : {},
body: formData
})
.then(response => {
if (response.ok) {
// Reload page to show new logo
window.location.reload();
} else {
throw new Error('Upload failed');
}
})
.catch(error => {
console.error('Upload error:', error);
alert(i18nSettings.logo_upload_failed || 'Logo upload failed. Please try again.');
// Reset button state
uploadBtn.innerHTML = originalText;
uploadBtn.disabled = false;
});
}
// File input change handler
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('logo_file');
if (fileInput) {
fileInput.addEventListener('change', function() {
previewLogo(this);
});
}
});
</script>
{% endblock %}

View File

@@ -1,95 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('System Info') }} - {{ app_name }}{% endblock %}
{% block content %}
{% from "_components.html" import page_header %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
{{ page_header('fas fa-info-circle', _('System Information'), _('System status and metrics'), None) }}
</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"><i class="fas fa-users"></i></div>
<div class="ms-3">
<div class="summary-label">{{ _('Total Users') }}</div>
<div class="summary-value">{{ total_users }}</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-project-diagram"></i></div>
<div class="ms-3">
<div class="summary-label">{{ _('Total Projects') }}</div>
<div class="summary-value">{{ 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-info bg-opacity-10 text-info"><i class="fas fa-clock"></i></div>
<div class="ms-3">
<div class="summary-label">{{ _('Time Entries') }}</div>
<div class="summary-value">{{ total_entries }}</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"><i class="fas fa-play"></i></div>
<div class="ms-3">
<div class="summary-label">{{ _('Active Timers') }}</div>
<div class="summary-value">{{ active_timers }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i> {{ _('System Details') }}
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-5 text-muted">{{ _('Total Users') }}</div>
<div class="col-sm-7"><strong>{{ total_users }}</strong></div>
</div>
<div class="row mb-3">
<div class="col-sm-5 text-muted">{{ _('Total Projects') }}</div>
<div class="col-sm-7"><strong>{{ total_projects }}</strong></div>
</div>
<div class="row mb-3">
<div class="col-sm-5 text-muted">{{ _('Time Entries') }}</div>
<div class="col-sm-7"><strong>{{ total_entries }}</strong></div>
</div>
<div class="row mb-3">
<div class="col-sm-5 text-muted">{{ _('Active Timers') }}</div>
<div class="col-sm-7"><strong>{{ active_timers }}</strong></div>
</div>
<div class="row">
<div class="col-sm-5 text-muted">{{ _('Database Size') }}</div>
<div class="col-sm-7"><span class="badge bg-info">{{ db_size_mb }} MB</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,196 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ (user and _('Edit') or _('New')) }} {{ _('User') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('admin.list_users') }}" class="btn-header btn-outline-primary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Users') }}
</a>
{% endset %}
{{ page_header('fas fa-user', (user and _('Edit User') or _('New User')), _('Create or update user accounts'), actions) }}
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-edit"></i> {{ _('User Information') }}
</h5>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="username" class="form-label">{{ _('Username') }}</label>
{% if user %}
<input type="text" id="username" class="form-control" value="{{ user.username }}" disabled>
<input type="hidden" name="username" value="{{ user.username }}">
{% else %}
<input type="text" id="username" name="username" class="form-control" value="{{ request.form.get('username', '') }}" required>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="role" class="form-label">{{ _('Role') }}</label>
<select id="role" name="role" class="form-select">
{% set selected_role = (user.role if user else request.form.get('role', 'user')) %}
<option value="user" {% if selected_role == 'user' %}selected{% endif %}>{{ _('User') }}</option>
<option value="admin" {% if selected_role == 'admin' %}selected{% endif %}>{{ _('Admin') }}</option>
</select>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
{% set active_checked = (user.is_active if user else (request.form.get('is_active', 'on') in ['on','true','1'])) %}
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" {% if active_checked %}checked{% endif %}>
<label class="form-check-label" for="is_active">{{ _('Active') }}</label>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{% if user %}{{ _('Update User') }}{% else %}{{ _('Create User') }}{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> {{ _('Help') }}
</h5>
</div>
<div class="card-body">
<h6>{{ _('Username') }}</h6>
<p class="text-muted small">{{ _('Choose a unique username for the user. This will be used for login.') }}</p>
<h6>{{ _('Role') }}</h6>
<p class="text-muted small">
<strong>{{ _('User:') }}</strong> {{ _('Can track time, view projects, and generate reports.') }}<br>
<strong>{{ _('Admin:') }}</strong> {{ _('Can manage users, projects, and system settings.') }}
</p>
<h6>{{ _('Active Status') }}</h6>
<p class="text-muted small">{{ _('Inactive users cannot log in or access the system.') }}</p>
{% if user %}
<hr>
<h6>{{ _('User Statistics') }}</h6>
<div class="row text-center">
<div class="col-6">
<div class="h5 text-primary">{{ "%.1f"|format(user.total_hours) }}</div>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6">
<div class="h5 text-success">{{ user.time_entries.count() }}</div>
<small class="text-muted">{{ _('Time Entries') }}</small>
</div>
</div>
<div class="mt-3">
<h6>{{ _('Account Information') }}</h6>
<small class="text-muted">
<div>{{ _('Created:') }} {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</div>
{% if user.last_login %}
<div>{{ _('Last Login:') }} {{ user.last_login.strftime('%Y-%m-%d %H:%M') }}</div>
{% else %}
<div>{{ _('Last Login: Never') }}</div>
{% endif %}
</small>
</div>
{% endif %}
</div>
</div>
{% if user and user.id != current_user.id %}
<div class="card mt-3 border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle"></i> {{ _('Danger Zone') }}
</h5>
</div>
<div class="card-body">
<p class="text-muted small">{{ _('These actions cannot be undone.') }}</p>
<button type="button" class="btn btn-danger btn-sm"
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
<i class="fas fa-trash"></i> {{ _('Delete User') }}
</button>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete User') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete the user') }} <strong id="deleteUserName"></strong>?</p>
<p class="text-muted mb-0">{{ _('This will permanently remove the user and all their data cannot be recovered.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteUserForm" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ csrf_token() }}
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>{{ _('Delete User') }}
</button>
</form>
</div>
</div>
</div>
</div>
<script>
// Function to show delete user modal
function showDeleteUserModal(userId, username) {
document.getElementById('deleteUserName').textContent = username;
document.getElementById('deleteUserForm').action = "{{ url_for('admin.delete_user', user_id=0) }}".replace('0', userId);
new bootstrap.Modal(document.getElementById('deleteUserModal')).show();
}
// Add loading state to delete user form
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;
});
});
</script>
{% endblock %}

View File

@@ -1,238 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('User Management') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i>{{ _('New User') }}
</a>
{% endset %}
{{ page_header('fas fa-users', _('User Management'), _('Manage users') ~ ' • ' ~ (users|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
<!-- User Statistics -->
<div class="row section-spacing">
<div class="col-md-3 mb-3">
<div class="card h-100 hover-lift">
<div class="card-body text-center">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 stats-icon">
<i class="fas fa-users text-primary fa-2x"></i>
</div>
<h3 class="h2 text-primary mb-2">{{ stats.total_users }}</h3>
<p class="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-user-check fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ stats.active_users }}</h4>
<p class="text-muted mb-0">{{ _('Active Users') }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-user-shield fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ stats.admin_users }}</h4>
<p class="text-muted mb-0">{{ _('Admin Users') }}</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">{{ "%.1f"|format(stats.total_hours) }}h</h4>
<p class="text-muted mb-0">{{ _('Total Hours') }}</p>
</div>
</div>
</div>
</div>
<!-- Users List -->
<div class="row">
<div class="col-12">
<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 p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover mb-0" id="usersTable">
<thead>
<tr>
<th>{{ _('User') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Created') }}</th>
<th>{{ _('Last Login') }}</th>
<th>{{ _('Total Hours') }}</th>
<th class="text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<div>
<strong>{{ user.display_name }}</strong>
{% if user.active_timer %}
<br><small class="text-warning">
<i class="fas fa-clock"></i> {{ _('Timer Running') }}
</small>
{% endif %}
</div>
</td>
<td>
{% if user.role == 'admin' %}
<span class="badge bg-warning">{{ _('Admin') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('User') }}</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">{{ _('Active') }}</span>
{% else %}
<span class="badge bg-danger">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.last_login %}
{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-muted">{{ _('Never') }}</span>
{% endif %}
</td>
<td>
<strong>{{ "%.1f"|format(user.total_hours) }}h</strong>
</td>
<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-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-action btn-action--danger" title="{{ _('Delete') }}"
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No 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>
</div>
</div>
</div>
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete User') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete the user') }} <strong id="deleteUserName"></strong>?</p>
<p class="text-muted mb-0">{{ _('This will permanently remove the user and all their data cannot be recovered.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteUserForm" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>{{ _('Delete User') }}
</button>
</form>
</div>
</div>
</div>
</div>
<script>
// Function to show delete user modal
function showDeleteUserModal(userId, username) {
document.getElementById('deleteUserName').textContent = username;
document.getElementById('deleteUserForm').action = "{{ url_for('admin.delete_user', user_id=0) }}".replace('0', userId);
new bootstrap.Modal(document.getElementById('deleteUserModal')).show();
}
// Add loading state to delete user form and wire table search
document.addEventListener('DOMContentLoaded', function() {
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 %}

View File

@@ -1,436 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Clients') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn-header btn-primary">
<i class="fas fa-plus me-2"></i>{{ _('New Client') }}
</a>
{% endif %}
{% endset %}
{{ page_header('fas fa-building', _('Clients'), _('Manage customers and contacts') ~ ' • ' ~ (clients|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
<!-- Client List -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm" style="border-radius: var(--border-radius);">
<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>
{% if current_user.is_admin %}
<div class="btn-group" id="bulkActionsGroup">
<button type="button" id="bulkActionsBtn" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" disabled>
<i class="fas fa-tasks me-1"></i> {{ _('Bulk Actions') }} (<span id="selectedCount">0</span>)
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('active')"><i class="fas fa-check-circle me-2 text-success"></i>{{ _('Mark as Active') }}</a></li>
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('inactive')"><i class="fas fa-pause-circle me-2 text-warning"></i>{{ _('Mark as Inactive') }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="showBulkDeleteConfirm()"><i class="fas fa-trash me-2"></i>{{ _('Delete') }}</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="card-body p-0">
{% if clients %}
<div class="table-responsive">
<table class="table table-hover mb-0" id="clientsTable">
<thead class="table-light">
<tr>
{% if current_user.is_admin %}
<th style="width: 40px;">
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllClients()">
</th>
{% endif %}
<th>{{ _('Name') }}</th>
<th>{{ _('Contact Person') }}</th>
<th>{{ _('Email') }}</th>
<th>{{ _('Phone') }}</th>
<th>{{ _('Default Rate') }}</th>
<th>{{ _('Projects') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr class="client-row" data-status="{{ client.status }}">
{% if current_user.is_admin %}
<td>
<input type="checkbox" class="client-checkbox form-check-input" value="{{ client.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
<td>
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-decoration-none">
<strong>{{ client.name }}</strong>
</a>
{% if client.description %}
<br><small class="text-muted">{{ client.description[:50] }}{% if client.description|length > 50 %}...{% endif %}</small>
{% endif %}
</td>
<td>{{ client.contact_person or '-' }}</td>
<td>
{% if client.email %}
<a href="mailto:{{ client.email }}">{{ client.email }}</a>
{% else %}
-
{% endif %}
</td>
<td>{{ client.phone or '-' }}</td>
<td>
{% if client.default_hourly_rate %}
{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}
{% else %}
-
{% endif %}
</td>
<td>
<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>
{% 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">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}"
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('clients.edit_client', client_id=client.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if client.status == 'active' %}
<button type="button" class="btn btn-sm btn-action btn-action--warning" title="{{ _('Archive') }}"
onclick="confirmArchiveClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-archive"></i>
</button>
{% else %}
<button type="button" class="btn btn-sm btn-action btn-action--success" title="{{ _('Activate') }}"
onclick="confirmActivateClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-check"></i>
</button>
{% endif %}
{% if client.total_projects == 0 %}
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete') }}"
onclick="confirmDeleteClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-building fa-3x text-muted mb-3"></i>
<h4 class="text-muted">{{ _('No Clients Found') }}</h4>
<p class="text-muted">{{ _('Get started by creating your first client.') }}</p>
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>{{ _('Create First Client') }}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modern styling now handled by global CSS in base.css -->
<!-- Bulk Action Forms (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('clients.bulk_delete_clients') }}" class="d-none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('clients.bulk_status_change') }}" class="d-none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
</form>
<!-- Bulk Status Change Confirmation Modal -->
<div class="modal fade" id="confirmBulkStatusChange" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-exchange-alt me-2 text-primary"></i>{{ _('Change Status') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="bulkStatusChangeMessage"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-primary" onclick="submitBulkStatusChange()">
<i class="fas fa-check me-2"></i>{{ _('Change Status') }}
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Delete Confirmation Modal -->
<div class="modal fade" id="confirmBulkDelete" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Selected Clients') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete the selected clients?') }}</p>
<p class="text-muted mb-0">{{ _('Clients with existing projects will be skipped.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-danger" onclick="submitBulkDelete()">
<i class="fas fa-trash me-2"></i>{{ _('Delete Clients') }}
</button>
</div>
</div>
</div>
</div>
{% block extra_js %}
<script type="application/json" id="i18n-json-clients-list">
{
"confirm_archive": {{ _('Are you sure you want to archive "{name}"?')|tojson }},
"confirm_activate": {{ _('Are you sure you want to activate "{name}"?')|tojson }},
"confirm_delete": {{ _('Are you sure you want to delete "{name}"? This action cannot be undone.')|tojson }}
}
</script>
<script>
var i18nClientsList = (function(){ try{ var el=document.getElementById('i18n-json-clients-list'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
</script>
<script>
// Bulk delete functions for clients
function toggleAllClients() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.client-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
const count = checkboxes.length;
const btnGroup = document.getElementById('bulkActionsGroup');
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
// Update select all checkbox state
const allCheckboxes = document.querySelectorAll('.client-checkbox');
const selectAll = document.getElementById('selectAll');
if (selectAll && allCheckboxes.length > 0) {
selectAll.checked = count === allCheckboxes.length;
selectAll.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function showBulkDeleteConfirm() {
new bootstrap.Modal(document.getElementById('confirmBulkDelete')).show();
}
function submitBulkDelete() {
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
const form = document.getElementById('confirmBulkDelete-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="client_ids[]"]').forEach(input => input.remove());
// Add selected client IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'client_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Close modal and submit form
bootstrap.Modal.getInstance(document.getElementById('confirmBulkDelete')).hide();
form.submit();
}
function showBulkStatusChange(newStatus) {
const count = document.querySelectorAll('.client-checkbox:checked').length;
const statusLabels = {
'active': '{{ _("Active") }}',
'inactive': '{{ _("Inactive") }}'
};
const message = `{{ _("Are you sure you want to mark {count} client(s) as {status}?") }}`
.replace('{count}', count)
.replace('{status}', statusLabels[newStatus] || newStatus);
document.getElementById('bulkStatusChangeMessage').textContent = message;
document.getElementById('bulkNewStatus').value = newStatus;
new bootstrap.Modal(document.getElementById('confirmBulkStatusChange')).show();
return false;
}
function submitBulkStatusChange() {
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
const form = document.getElementById('bulkStatusChange-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="client_ids[]"]').forEach(input => input.remove());
// Add selected client IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'client_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Close modal and submit form
bootstrap.Modal.getInstance(document.getElementById('confirmBulkStatusChange')).hide();
form.submit();
}
// 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();
})();
// Client action confirmation functions using global modal
function confirmArchiveClient(clientId, clientName) {
const msg = (i18nClientsList.confirm_archive || 'Are you sure you want to archive "{name}"?').replace('{name}', clientName);
window.showConfirm(msg).then(function(ok){
if (!ok) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = `/clients/${clientId}/archive`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
});
}
function confirmActivateClient(clientId, clientName) {
const msg = (i18nClientsList.confirm_activate || 'Are you sure you want to activate "{name}"?').replace('{name}', clientName);
window.showConfirm(msg).then(function(ok){
if (!ok) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = `/clients/${clientId}/activate`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
});
}
function confirmDeleteClient(clientId, clientName) {
const msg = (i18nClientsList.confirm_delete || 'Are you sure you want to delete "{name}"? This action cannot be undone.').replace('{name}', clientName);
window.showConfirm(msg).then(function(ok){
if (!ok) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = `/clients/${clientId}/delete`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
});
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -1,339 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ client.name }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="btn btn-outline-light btn-sm me-2">
<i class="fas fa-edit"></i> {{ _('Edit Client') }}
</a>
{% endif %}
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
</a>
{% endset %}
{{ page_header('fas fa-building', client.name, _('Client details and project overview'), actions) }}
</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 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">
<div class="detail-row">
<span class="detail-label">{{ _('Status') }}</span>
<span class="detail-value">
{% if client.status == 'active' %}
<span class="status-badge-large bg-success text-white">{{ _('Active') }}</span>
{% else %}
<span class="status-badge-large bg-secondary text-white">{{ _('Archived') }}</span>
{% endif %}
</span>
</div>
</div>
{% if client.description %}
<div class="mb-3">
<div class="section-title text-primary mb-2">{{ _('Description') }}</div>
<div class="content-box prose dark:prose-invert max-w-none">{{ client.description | markdown | safe }}</div>
</div>
{% endif %}
{% if client.contact_person %}
<div class="mb-3">
<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">
<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">
<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">
<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">
<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 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">
<div class="col-6">
<h4 class="text-primary">{{ client.total_projects }}</h4>
<small class="text-muted">{{ _('Total Projects') }}</small>
</div>
<div class="col-6">
<h4 class="text-success">{{ client.active_projects }}</h4>
<small class="text-muted">{{ _('Active Projects') }}</small>
</div>
</div>
<hr>
<div class="row text-center">
<div class="col-6">
<h4 class="text-info">{{ "%.1f"|format(client.total_hours) }}</h4>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6">
<h4 class="text-warning">{{ "%.2f"|format(client.estimated_total_cost) }}</h4>
<small class="text-muted">{{ _('Est. Total Cost') }}</small>
</div>
</div>
</div>
</div>
<!-- Client Actions -->
{% if current_user.is_admin %}
<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" data-confirm="{{ _('Are you sure you want to archive this client?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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) }}" data-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 me-2"></i>{{ _('Delete Client') }}
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- Projects List -->
<div class="col-lg-8">
<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-sm btn-outline-primary">
<i class="fas fa-plus me-1"></i> {{ _('New Project') }}
</a>
{% endif %}
</div>
<div class="card-body p-0">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>{{ _('Project') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Billable') }}</th>
<th>{{ _('Hourly Rate') }}</th>
<th>{{ _('Total Hours') }}</th>
<th>{{ _('Est. Cost') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-decoration-none">
<strong>{{ project.name }}</strong>
</a>
{% if project.code_display %}
<span class="badge bg-light text-dark border ms-2 align-middle">{{ project.code_display }}</span>
{% endif %}
{% if project.description %}
<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 badge-soft-success">{{ _('Active') }}</span>
{% else %}
<span class="badge badge-soft-secondary">{{ _('Archived') }}</span>
{% endif %}
</td>
<td>
{% if project.billable %}
<span class="badge badge-soft-primary">{{ _('Yes') }}</span>
{% else %}
<span class="badge badge-soft-secondary">{{ _('No') }}</span>
{% endif %}
</td>
<td>
{% if project.hourly_rate %}
{{ "%.2f"|format(project.hourly_rate) }} {{ currency }}
{% else %}
-
{% endif %}
</td>
<td>{{ "%.1f"|format(project.total_hours) }}</td>
<td>
{% if project.billable and project.hourly_rate %}
{{ "%.2f"|format(project.estimated_cost) }} {{ currency }}
{% else %}
-
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view" title="{{ _('View') }}">
<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-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<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>
</div>
</div>
</div>
</div>
<!-- Modern styling now handled by global CSS in base.css -->
<script>
// Generic data-confirm handler
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('form[data-confirm]').forEach(function(f){
f.addEventListener('submit', function(e){
var msg = f.getAttribute('data-confirm') || '';
if (msg && !confirm(msg)) { e.preventDefault(); return false; }
});
});
});
</script>
{% endblock %}

View File

@@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('400 Bad Request') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<i class="fas fa-exclamation-triangle"></i> {{ _('400 Bad Request') }}
</h4>
</div>
<div class="card-body text-center">
<h5 class="card-title">{{ _('Invalid Request') }}</h5>
<p class="card-text">
{{ _('The request you made is invalid or contains errors. This could be due to:') }}
</p>
<ul class="list-unstyled text-start">
<li><i class="fas fa-check text-success"></i> {{ _('Missing or invalid form data') }}</li>
<li><i class="fas fa-check text-success"></i> {{ _('Malformed request parameters') }}</li>
</ul>
<div class="mt-4">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> {{ _('Go to Dashboard') }}
</a>
<button onclick="history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,38 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('403 Forbidden') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="fas fa-ban"></i> {{ _('403 Forbidden') }}
</h4>
</div>
<div class="card-body text-center">
<h5 class="card-title">{{ _('Access Denied') }}</h5>
<p class="card-text">
{{ _("You don't have permission to access this resource. This could be due to:") }}
</p>
<ul class="list-unstyled text-start">
<li><i class="fas fa-check text-success"></i> {{ _('Insufficient privileges') }}</li>
<li><i class="fas fa-check text-success"></i> {{ _('Not logged in') }}</li>
<li><i class="fas fa-check text-success"></i> {{ _('Resource access restrictions') }}</li>
</ul>
<div class="mt-4">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> {{ _('Go to Dashboard') }}
</a>
<a href="{{ url_for('auth.login') }}" class="btn btn-secondary">
<i class="fas fa-sign-in-alt"></i> {{ _('Login') }}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Page Not Found') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="py-5">
<i class="fas fa-exclamation-triangle fa-5x text-warning mb-4"></i>
<h1 class="display-4 text-muted">404</h1>
<h2 class="h4 mb-3">{{ _('Page Not Found') }}</h2>
<p class="text-muted mb-4">
{{ _("The page you're looking for doesn't exist or has been moved.") }}
</p>
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> {{ _('Go to Dashboard') }}
</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Server Error') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="py-5">
<i class="fas fa-bug fa-5x text-danger mb-4"></i>
<h1 class="display-4 text-muted">500</h1>
<h2 class="h4 mb-3">{{ _('Server Error') }}</h2>
<p class="text-muted mb-4">
{{ _('Something went wrong on our end. Please try again later.') }}
</p>
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> {{ _('Go to Dashboard') }}
</a>
<a href="javascript:location.reload()" class="btn btn-outline-secondary">
<i class="fas fa-redo"></i> {{ _('Try Again') }}
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,735 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Create Invoice') }} - TimeTracker{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Header with Progress Indicator -->
<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-plus text-primary"></i>
{{ _('Create New Invoice') }}
</h1>
<div class="progress" style="width: 200px; height: 8px;">
<div class="progress-bar" role="progressbar" style="width: 25%" id="progressBar"></div>
</div>
</div>
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Invoices') }}
</a>
</div>
<!-- Step Indicator -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body py-3">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center step-indicator active" data-step="1">
<div class="step-number">1</div>
<span class="step-label ms-2">{{ _('Basic Info') }}</span>
</div>
<div class="step-line"></div>
<div class="d-flex align-items-center step-indicator" data-step="2">
<div class="step-number">2</div>
<span class="step-label ms-2">{{ _('Client Details') }}</span>
</div>
<div class="step-line"></div>
<div class="d-flex align-items-center step-indicator" data-step="3">
<div class="step-number">3</div>
<span class="step-label ms-2">{{ _('Settings') }}</span>
</div>
<div class="step-line"></div>
<div class="d-flex align-items-center step-indicator" data-step="4">
<div class="step-number">4</div>
<span class="step-label ms-2">{{ _('Review') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<form method="POST" id="invoiceForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-lg-8">
<!-- Step 1: Basic Information -->
<div class="card shadow step-card active" id="step1">
<div class="card-header py-3 bg-primary text-white">
<h6 class="m-0 font-weight-bold">
<i class="fas fa-info-circle me-2"></i>{{ _('Step 1: Basic Information') }}
</h6>
</div>
<div class="card-body">
<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="">{{ _('Select a project...') }}</option>
{% for project in projects %}
<option value="{{ project.id }}"
data-client="{{ project.client }}"
data-client-id="{{ project.client_id }}"
data-client-email="{{ project.client_obj.email if project.client_obj else '' }}"
data-client-address="{{ project.client_obj.address if project.client_obj else '' }}"
data-hourly-rate="{{ project.hourly_rate or 0 }}">
{{ project.name }} ({{ project.client }})
</option>
{% endfor %}
</select>
<label for="project_id">{{ _('Project *') }}</label>
<div class="form-text">
<i class="fas fa-lightbulb text-warning me-1"></i>
{{ _('Select the project this invoice is for') }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="date" class="form-control" id="due_date" name="due_date"
value="{{ default_due_date }}" required>
<label for="due_date">{{ _('Due Date *') }}</label>
<div class="form-text">
<i class="fas fa-calendar text-info me-1"></i>
{{ _('When payment is due') }}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-primary" onclick="nextStep()">
{{ _('Next: Client Details') }} <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
<!-- Step 2: Client Details -->
<div class="card shadow step-card" id="step2" style="display: none;">
<div class="card-header py-3 bg-info text-white">
<h6 class="m-0 font-weight-bold">
<i class="fas fa-user me-2"></i>{{ _('Step 2: Client Details') }}
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="client_name" class="form-label">{{ _('Client Name *') }}</label>
<input type="text" class="form-control" id="client_name" name="client_name"
placeholder="{{ _('Client or company name') }}" required>
<div class="form-text">
<i class="fas fa-building text-primary me-1"></i>
{{ _('Who to bill') }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="client_email" class="form-label">{{ _('Client Email') }}</label>
<div class="input-group">
<input type="email" class="form-control" id="client_email" name="client_email"
placeholder="{{ _('client@example.com') }}">
<button type="button" class="btn btn-outline-secondary" id="reset_email_btn"
title="{{ _('Reset to original client email') }}" style="display: none;">
<i class="fas fa-undo"></i>
</button>
</div>
<div class="form-text">
<i class="fas fa-envelope text-muted me-1"></i>
{{ _('Optional email for sending invoices') }}
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="client_address" class="form-label">{{ _('Client Address') }}</label>
<div class="input-group">
<textarea class="form-control" id="client_address" name="client_address"
style="height: 100px" placeholder="{{ _('Client billing address (optional)') }}"></textarea>
<button type="button" class="btn btn-outline-secondary" id="reset_address_btn"
title="{{ _('Reset to original client address') }}" style="display: none;">
<i class="fas fa-undo"></i>
</button>
</div>
<div class="form-text">
<i class="fas fa-map-marker-alt text-muted me-1"></i>
{{ _('Optional billing address') }}
</div>
</div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep()">
<i class="fas fa-arrow-left me-1"></i> {{ _('Previous') }}
</button>
<button type="button" class="btn btn-primary" onclick="nextStep()">
{{ _('Next: Settings') }} <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
<!-- Step 3: Settings -->
<div class="card shadow step-card" id="step3" style="display: none;">
<div class="card-header py-3 bg-warning text-dark">
<h6 class="m-0 font-weight-bold">
<i class="fas fa-cog me-2"></i>{{ _('Step 3: Settings') }}
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="number" class="form-control" id="tax_rate" name="tax_rate"
value="0" min="0" max="100" step="0.01">
<label for="tax_rate">{{ _('Tax Rate (%)') }}</label>
<div class="form-text">
<i class="fas fa-percentage text-success me-1"></i>
{{ _('Tax rate as percentage (e.g., 20 for 20%)') }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="currency"
value="{{ settings.currency or 'EUR' }}" readonly>
<label for="currency">{{ _('Currency') }}</label>
<div class="form-text">
<i class="fas fa-coins text-warning me-1"></i>
{{ _('System default currency') }}
</div>
</div>
</div>
</div>
<div class="form-floating mb-3">
<textarea class="form-control" id="notes" name="notes"
style="height: 100px" placeholder="{{ _('Additional notes for the client') }}"></textarea>
<label for="notes">{{ _('Notes') }}</label>
<div class="form-text">
<i class="fas fa-sticky-note text-info me-1"></i>
{{ _('Optional notes to include on the invoice') }}
</div>
</div>
<div class="form-floating mb-3">
<textarea class="form-control" id="terms" name="terms"
style="height: 100px" placeholder="{{ _('Payment terms and conditions') }}"></textarea>
<label for="terms">{{ _('Terms & Conditions') }}</label>
<div class="form-text">
<i class="fas fa-file-contract text-primary me-1"></i>
{{ _('Payment terms and conditions') }}
</div>
</div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep()">
<i class="fas fa-arrow-left me-1"></i> {{ _('Previous') }}
</button>
<button type="button" class="btn btn-primary" onclick="nextStep()">
{{ _('Next: Review') }} <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
<!-- Step 4: Review -->
<div class="card shadow step-card" id="step4" style="display: none;">
<div class="card-header py-3 bg-success text-white">
<h6 class="m-0 font-weight-bold">
<i class="fas fa-check-circle me-2"></i>{{ _('Step 4: Review & Create') }}
</h6>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>{{ _('Review your invoice details below before creating:') }}</strong>
</div>
<div class="row">
<div class="col-md-6">
<h6 class="text-primary mb-3">{{ _('Invoice Summary') }}</h6>
<div class="mb-2">
<strong>{{ _('Project:') }}</strong> <span id="review-project">-</span>
</div>
<div class="mb-2">
<strong>{{ _('Client:') }}</strong> <span id="review-client">-</span>
</div>
<div class="mb-2">
<strong>{{ _('Due Date:') }}</strong> <span id="review-due-date">-</span>
</div>
<div class="mb-2">
<strong>{{ _('Tax Rate:') }}</strong> <span id="review-tax">-</span>
</div>
</div>
<div class="col-md-6">
<h6 class="text-primary mb-3">{{ _('Additional Details') }}</h6>
<div class="mb-2">
<strong>{{ _('Notes:') }}</strong> <span id="review-notes">-</span>
</div>
<div class="mb-2">
<strong>{{ _('Terms:') }}</strong> <span id="review-terms">-</span>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep()">
<i class="fas fa-arrow-left me-1"></i> {{ _('Previous') }}
</button>
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-save me-2"></i>{{ _('Create Invoice') }}
</button>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Project Information Card -->
<div class="card shadow mb-3">
<div class="card-header py-3 bg-light">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-project-diagram me-2"></i>Project Information
</h6>
</div>
<div class="card-body">
<div id="project-info" class="d-none">
<div class="info-item mb-3">
<div class="info-label">Client</div>
<div class="info-value" id="project-client">-</div>
</div>
<div class="info-item mb-3">
<div class="info-label">Hourly Rate</div>
<div class="info-value" id="project-rate">{{ settings.currency or 'EUR' }}0.00</div>
</div>
<div class="info-item mb-3">
<div class="info-label">Billable</div>
<div class="info-value" id="project-billable">-</div>
</div>
</div>
<div id="no-project-info" class="text-center text-muted py-3">
<i class="fas fa-info-circle fa-2x mb-2"></i>
<div>{{ _('Select a project to see details') }}</div>
</div>
</div>
</div>
<!-- Quick Tips Card -->
<div class="card shadow">
<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">
<div class="tip-icon text-warning">
<i class="fas fa-clock"></i>
</div>
<div class="tip-content">
<strong>{{ _('Time Integration') }}</strong>
<p class="small text-muted mb-0">
{{ _('Generate line items from tracked time entries after creation') }}
</p>
</div>
</div>
<div class="tip-item mb-3">
<div class="tip-icon text-success">
<i class="fas fa-calculator"></i>
</div>
<div class="tip-content">
<strong>{{ _('Auto Calculations') }}</strong>
<p class="small text-muted mb-0">
{{ _('Tax and totals computed automatically') }}
</p>
</div>
</div>
<div class="tip-item">
<div class="tip-icon text-primary">
<i class="fas fa-file-export"></i>
</div>
<div class="tip-content">
<strong>{{ _('Export Options') }}</strong>
<p class="small text-muted mb-0">
{{ _('CSV export and PDF generation available') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<style>
.step-indicator {
display: flex;
align-items: center;
color: var(--text-muted);
transition: all 0.3s ease;
}
.step-indicator.active {
color: var(--primary-color);
}
.step-number {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--light-color);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
transition: all 0.3s ease;
}
.step-indicator.active .step-number {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.step-line {
flex: 1;
height: 2px;
background: var(--border-color);
margin: 0 15px;
}
.step-indicator.active + .step-line {
background: var(--primary-color);
}
.step-label {
font-weight: 500;
font-size: 14px;
}
.step-card {
transition: all 0.3s ease;
border: 2px solid transparent;
}
.step-card.active {
border-color: var(--primary-color);
}
.info-item {
padding: 12px;
background: var(--light-color);
border-radius: var(--border-radius-sm);
}
.info-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.tip-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.tip-icon {
font-size: 18px;
margin-top: 2px;
}
.tip-content strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label {
color: var(--primary-color);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
/* Custom styling for input groups with reset buttons */
.input-group .btn {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-group .form-control:focus + .btn {
border-color: var(--primary-color);
}
/* Special handling for textarea in input group */
.input-group textarea {
resize: vertical;
min-height: 100px;
}
.input-group textarea + .btn {
align-self: flex-start;
height: 38px;
margin-top: 0;
}
@media (max-width: 768px) {
.step-indicator .step-label {
display: none;
}
.step-line {
margin: 0 8px;
}
.progress {
width: 150px !important;
}
}
</style>
{% endblock %}
{% block extra_js %}
<script>
let currentStep = 1;
const totalSteps = 4;
document.addEventListener('DOMContentLoaded', function() {
// Auto-fill client information when project is selected
const projectSelect = document.getElementById('project_id');
projectSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const clientName = selectedOption ? selectedOption.getAttribute('data-client') : '';
const clientEmail = selectedOption ? selectedOption.getAttribute('data-client-email') : '';
const clientAddress = selectedOption ? selectedOption.getAttribute('data-client-address') : '';
const hourlyRate = selectedOption ? parseFloat(selectedOption.getAttribute('data-hourly-rate') || '0') : 0;
const clientNameInput = document.getElementById('client_name');
const clientEmailInput = document.getElementById('client_email');
const clientAddressInput = document.getElementById('client_address');
const projectInfo = document.getElementById('project-info');
const noProjectInfo = document.getElementById('no-project-info');
const projectClient = document.getElementById('project-client');
const projectRate = document.getElementById('project-rate');
const projectBillable = document.getElementById('project-billable');
if (clientName) {
clientNameInput.value = clientName;
clientEmailInput.value = clientEmail || '';
clientAddressInput.value = clientAddress || '';
// Store original values for reset functionality
clientEmailInput.setAttribute('data-original', clientEmail || '');
clientAddressInput.setAttribute('data-original', clientAddress || '');
projectInfo.classList.remove('d-none');
noProjectInfo.classList.add('d-none');
projectClient.textContent = clientName;
projectRate.textContent = '{{ settings.currency or "EUR" }}' + hourlyRate.toFixed(2);
projectBillable.textContent = hourlyRate > 0 ? '{{ _('Yes') }}' : '{{ _('No') }}';
// Update reset buttons after populating fields
updateResetButtons();
} else {
projectInfo.classList.add('d-none');
noProjectInfo.classList.remove('d-none');
}
});
// Set minimum due date to today
const dueDate = document.getElementById('due_date');
const today = new Date().toISOString().split('T')[0];
dueDate.setAttribute('min', today);
// Handle reset buttons for client email and address
const clientEmailInput = document.getElementById('client_email');
const clientAddressInput = document.getElementById('client_address');
const resetEmailBtn = document.getElementById('reset_email_btn');
const resetAddressBtn = document.getElementById('reset_address_btn');
// Show/hide reset buttons based on field changes
function updateResetButtons() {
const emailOriginal = clientEmailInput.getAttribute('data-original') || '';
const addressOriginal = clientAddressInput.getAttribute('data-original') || '';
resetEmailBtn.style.display = (clientEmailInput.value !== emailOriginal) ? 'block' : 'none';
resetAddressBtn.style.display = (clientAddressInput.value !== addressOriginal) ? 'block' : 'none';
}
// Add event listeners for field changes
clientEmailInput.addEventListener('input', updateResetButtons);
clientAddressInput.addEventListener('input', updateResetButtons);
// Reset button functionality
resetEmailBtn.addEventListener('click', function() {
const original = clientEmailInput.getAttribute('data-original') || '';
clientEmailInput.value = original;
updateResetButtons();
});
resetAddressBtn.addEventListener('click', function() {
const original = clientAddressInput.getAttribute('data-original') || '';
clientAddressInput.value = original;
updateResetButtons();
});
// Form validation
const invoiceForm = document.getElementById('invoiceForm');
invoiceForm.addEventListener('submit', function(e) {
if (!validateForm()) {
e.preventDefault();
showAlert('{{ _('Please fill in all required fields and complete all steps.') }}', 'warning');
}
});
});
function nextStep() {
if (validateCurrentStep()) {
if (currentStep < totalSteps) {
hideStep(currentStep);
currentStep++;
if (currentStep === 4) {
updateReview();
}
showStep(currentStep);
updateProgress();
updateStepIndicators();
}
}
}
function previousStep() {
if (currentStep > 1) {
hideStep(currentStep);
currentStep--;
showStep(currentStep);
updateProgress();
updateStepIndicators();
}
}
function showStep(step) {
const card = document.getElementById(`step${step}`);
if (card) {
card.style.display = '';
card.classList.add('active');
}
}
function hideStep(step) {
const card = document.getElementById(`step${step}`);
if (card) {
card.style.display = 'none';
card.classList.remove('active');
}
}
function updateProgress() {
const progress = (currentStep / totalSteps) * 100;
const bar = document.getElementById('progressBar');
bar.style.width = progress + '%';
}
function updateStepIndicators() {
document.querySelectorAll('.step-indicator').forEach(el => el.classList.remove('active'));
const active = document.querySelector(`.step-indicator[data-step="${currentStep}"]`);
if (active) active.classList.add('active');
}
function validateCurrentStep() {
let isValid = true;
const projectId = document.getElementById('project_id').value;
const dueDate = document.getElementById('due_date').value;
const clientName = document.getElementById('client_name').value;
switch(currentStep) {
case 1:
if (!projectId || !dueDate) {
isValid = false;
}
break;
case 2:
if (!clientName) {
isValid = false;
}
break;
case 3:
// All fields are optional in step 3
break;
}
if (!isValid) {
showAlert('{{ _('Please fill in all required fields before proceeding.') }}', 'warning');
}
return isValid;
}
function validateForm() {
return (
document.getElementById('project_id').value &&
document.getElementById('due_date').value &&
document.getElementById('client_name').value
);
}
function showAlert(message, type) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
if (container) {
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) alert.remove();
}, 5000);
}
}
// Update review section when moving to step 4
function updateReview() {
const projectSelect = document.getElementById('project_id');
const projectText = projectSelect.options[projectSelect.selectedIndex]?.text || '-';
document.getElementById('review-project').textContent = projectText;
document.getElementById('review-client').textContent = document.getElementById('client_name').value || '-';
document.getElementById('review-due-date').textContent = document.getElementById('due_date').value || '-';
const tax = document.getElementById('tax_rate').value;
document.getElementById('review-tax').textContent = tax ? `${tax}%` : '0%';
document.getElementById('review-notes').textContent = document.getElementById('notes').value || 'None';
document.getElementById('review-terms').textContent = document.getElementById('terms').value || 'None';
}
</script>
{% endblock %}

View File

@@ -1,380 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Edit Invoice') }} {{ invoice.invoice_number }} - TimeTracker{% 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">
<h1 class="h3 mb-0">
<i class="fas fa-edit text-primary"></i>
{{ _('Edit Invoice') }} {{ invoice.invoice_number }}
</h1>
<div class="btn-group" role="group">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-secondary">
<i class="fas fa-eye"></i> {{ _('View') }}
</a>
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back') }}
</a>
</div>
</div>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-lg-8">
<!-- Invoice Details -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{ _('Invoice Details') }}</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="client_name" class="form-label">{{ _('Client Name *') }}</label>
<input type="text" class="form-control" id="client_name" name="client_name"
value="{{ invoice.client_name }}" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="client_email" class="form-label">{{ _('Client Email') }}</label>
<input type="email" class="form-control" id="client_email" name="client_email"
value="{{ invoice.client_email or '' }}" placeholder="client@example.com">
</div>
</div>
</div>
<div class="mb-3">
<label for="client_address" class="form-label">{{ _('Client Address') }}</label>
<textarea class="form-control" id="client_address" name="client_address"
rows="3" placeholder="Client billing address">{{ invoice.client_address or '' }}</textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="due_date" class="form-label">{{ _('Due Date *') }}</label>
<input type="date" class="form-control" id="due_date" name="due_date"
value="{{ invoice.due_date.strftime('%Y-%m-%d') }}" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="tax_rate" class="form-label">{{ _('Tax Rate (%)') }}</label>
<input type="number" class="form-control" id="tax_rate" name="tax_rate"
value="{{ "%.2f"|format(invoice.tax_rate) }}" min="0" max="100" step="0.01">
</div>
</div>
</div>
<div class="mb-3">
<label for="notes" class="form-label">{{ _('Notes') }}</label>
<textarea class="form-control" id="notes" name="notes"
rows="3" placeholder="Additional notes for the client">{{ invoice.notes or '' }}</textarea>
</div>
<div class="mb-3">
<label for="terms" class="form-label">{{ _('Terms & Conditions') }}</label>
<textarea class="form-control" id="terms" name="terms"
rows="3" placeholder="Payment terms and conditions">{{ invoice.terms or '' }}</textarea>
</div>
</div>
</div>
<!-- Invoice Items -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">{{ _('Invoice Items') }}</h6>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addItemRow()">
<i class="fas fa-plus"></i> {{ _('Add Item') }}
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="itemsTable">
<thead class="table-light">
<tr>
<th style="width: 40%">{{ _('Description *') }}</th>
<th style="width: 20%">{{ _('Quantity (Hours) *') }}</th>
<th style="width: 20%">{{ _('Unit Price *') }}</th>
<th style="width: 20%">{{ _('Total') }}</th>
<th style="width: 10%">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody id="itemsTableBody">
{% for item in invoice.items %}
<tr class="item-row">
<td>
<input type="hidden" name="item_id[]" value="{{ item.id }}">
<input type="text" class="form-control" name="description[]"
value="{{ item.description }}" required>
</td>
<td>
<input type="number" class="form-control quantity-input"
name="quantity[]" value="{{ "%.2f"|format(item.quantity) }}"
min="0" step="0.01" required onchange="calculateRowTotal(this)">
</td>
<td>
<input type="number" class="form-control price-input"
name="unit_price[]" value="{{ "%.2f"|format(item.unit_price) }}"
min="0" step="0.01" required onchange="calculateRowTotal(this)">
</td>
<td>
<input type="text" class="form-control row-total"
value="{{ "%.2f"|format(item.total_amount) }} {{ currency }}" readonly>
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItemRow(this)">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="2" class="text-end"><strong>{{ _('Subtotal:') }}</strong></td>
<td colspan="2">
<input type="text" class="form-control" id="subtotal"
value="{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}" readonly>
</td>
<td></td>
</tr>
<tr>
<td colspan="2" class="text-end">
<strong>{{ _('Tax') }} ({{ "%.2f"|format(invoice.tax_rate) }}%):</strong>
</td>
<td colspan="2">
<input type="text" class="form-control" id="taxAmount"
value="{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}" readonly>
</td>
<td></td>
</tr>
<tr class="table-primary">
<td colspan="2" class="text-end"><strong>{{ _('Total Amount:') }}</strong></td>
<td colspan="2">
<input type="text" class="form-control" id="totalAmount"
value="{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}" readonly>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Project Information -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{ _('Project Information') }}</h6>
</div>
<div class="card-body">
<div class="mb-3">
<strong>{{ _('Project:') }}</strong><br>
<span class="text-muted">{{ invoice.project.name }}</span>
</div>
<div class="mb-3">
<strong>{{ _('Client:') }}</strong><br>
<span class="text-muted">{{ invoice.project.client }}</span>
</div>
<div class="mb-3">
<strong>{{ _('Hourly Rate:') }}</strong><br>
<span class="text-muted">
{% if invoice.project.hourly_rate %}
{{ "%.2f"|format(invoice.project.hourly_rate) }} {{ currency }}
{% else %}
{{ _('Not set') }}
{% endif %}
</span>
</div>
<div class="mb-3">
<strong>{{ _('Billing Reference:') }}</strong><br>
<span class="text-muted">{{ invoice.project.billing_ref or 'None' }}</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{ _('Quick Actions') }}</h6>
</div>
<div class="card-body">
<div class="mb-3">
<a href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}"
class="btn btn-primary w-100">
<i class="fas fa-clock"></i> {{ _('Generate from Time Entries') }}
</a>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary w-100" onclick="useProjectRate()">
<i class="fas fa-dollar-sign"></i> {{ _('Use Project Hourly Rate') }}
</button>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-info w-100" onclick="calculateTotals()">
<i class="fas fa-calculator"></i> {{ _('Recalculate Totals') }}
</button>
</div>
</div>
</div>
<!-- Totals Summary -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{ _('Totals Summary') }}</h6>
</div>
<div class="card-body">
<div class="row mb-2">
<div class="col-6"><strong>{{ _('Subtotal:') }}</strong></div>
<div class="col-6 text-end" id="summarySubtotal">
{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}
</div>
</div>
<div class="row mb-2">
<div class="col-6"><strong>{{ _('Tax:') }}</strong></div>
<div class="col-6 text-end" id="summaryTax">
{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}
</div>
</div>
<hr>
<div class="row">
<div class="col-6"><strong>{{ _('Total:') }}</strong></div>
<div class="col-6 text-end" id="summaryTotal">
{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="row mt-4">
<div class="col-12">
<div class="card shadow">
<div class="card-body">
<div class="d-flex justify-content-between">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{{ _('Update Invoice') }}
</button>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let itemCounter = {{ invoice.items.count() }};
function addItemRow() {
itemCounter++;
const newRow = `
<tr class="item-row">
<td>
<input type="hidden" name="item_id[]" value="">
<input type="text" class="form-control" name="description[]" placeholder="Item description" required>
</td>
<td>
<input type="number" class="form-control quantity-input" name="quantity[]"
value="1.00" min="0" step="0.01" required onchange="calculateRowTotal(this)">
</td>
<td>
<input type="number" class="form-control price-input" name="unit_price[]"
value="0.00" min="0" step="0.01" required onchange="calculateRowTotal(this)">
</td>
<td>
<input type="text" class="form-control row-total" value="0.00 {{ currency }}" readonly>
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItemRow(this)">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
document.getElementById('itemsTableBody').insertAdjacentHTML('beforeend', newRow);
}
function removeItemRow(button) {
const itemRows = document.querySelectorAll('.item-row');
if (itemRows.length > 1) {
button.closest('tr').remove();
calculateTotals();
} else {
alert('{{ _('At least one item is required.') }}');
}
}
function calculateRowTotal(input) {
const row = input.closest('tr');
const quantity = parseFloat(row.querySelector('.quantity-input').value) || 0;
const price = parseFloat(row.querySelector('.price-input').value) || 0;
const total = quantity * price;
row.querySelector('.row-total').value = total.toFixed(2) + ' {{ currency }}';
calculateTotals();
}
function calculateTotals() {
let subtotal = 0;
document.querySelectorAll('.item-row').forEach(function(row) {
const quantity = parseFloat(row.querySelector('.quantity-input').value) || 0;
const price = parseFloat(row.querySelector('.price-input').value) || 0;
subtotal += quantity * price;
});
const taxRate = parseFloat(document.getElementById('tax_rate').value) || 0;
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
// Update summary fields
document.getElementById('subtotal').value = subtotal.toFixed(2) + ' {{ currency }}';
document.getElementById('taxAmount').value = taxAmount.toFixed(2) + ' {{ currency }}';
document.getElementById('totalAmount').value = total.toFixed(2) + ' {{ currency }}';
// Update summary display
document.getElementById('summarySubtotal').textContent = subtotal.toFixed(2) + ' {{ currency }}';
document.getElementById('summaryTax').textContent = taxAmount.toFixed(2) + ' {{ currency }}';
document.getElementById('summaryTotal').textContent = total.toFixed(2) + ' {{ currency }}';
}
function useProjectRate() {
const projectRate = {{ invoice.project.hourly_rate or 0 }};
if (projectRate > 0) {
document.querySelectorAll('.price-input').forEach(function(input) {
input.value = projectRate.toFixed(2);
calculateRowTotal(input);
});
} else {
alert('{{ _('No hourly rate set for this project.') }}');
}
}
// Initialize calculations on page load
document.addEventListener('DOMContentLoaded', function() {
calculateTotals();
// Set minimum due date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('due_date').setAttribute('min', today);
});
</script>
{% endblock %}

View File

@@ -1,586 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Generate Invoice from Time') }} - TimeTracker{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Enhanced Header -->
<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>
{{ _('Generate Invoice Items from Time Entries') }}
</h1>
<span class="badge bg-info fs-6">{{ _('Invoice #') }}{{ invoice.invoice_number }}</span>
</div>
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {{ _('Back to Invoice') }}
</a>
</div>
<!-- Invoice Summary Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="summary-item">
<div class="summary-label">{{ _('Client') }}</div>
<div class="summary-value">{{ invoice.client_name }}</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-item">
<div class="summary-label">{{ _('Project') }}</div>
<div class="summary-value">{{ invoice.project.name }}</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-item">
<div class="summary-label">{{ _('Hourly Rate') }}</div>
<div class="summary-value">{{ "%.2f"|format(invoice.project.hourly_rate or 0) }} {{ currency }}</div>
</div>
</div>
<div class="col-md-3">
<div class="summary-item">
<div class="summary-label">{{ _('Available Hours') }}</div>
<div class="summary-value text-primary">{{ "%.2f"|format(total_available_hours) }}h</div>
</div>
</div>
</div>
</div>
</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">
<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>{{ _('Select Time Entries') }}
</h6>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="selectRecentEntries()">
<i class="fas fa-calendar-week me-1"></i> {{ _('Last 7 Days') }}
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="selectThisMonth()">
<i class="fas fa-calendar-alt me-1"></i> {{ _('This Month') }}
</button>
</div>
</div>
</div>
<div class="card-body">
{% if time_entries %}
<form method="POST" id="timeEntriesForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Selection Controls -->
<div class="selection-controls mb-4">
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAll" onchange="toggleAllEntries()">
<label class="form-check-label fw-bold" for="selectAll">
{{ _('Select All Time Entries') }}
</label>
</div>
<div class="selection-summary">
<span class="badge bg-primary me-2">
<i class="fas fa-check me-1"></i>
<span id="selectedCount">0</span> {{ _('selected') }}
</span>
<span class="badge bg-success">
<i class="fas fa-clock me-1"></i>
<span id="totalHours">0.00</span> {{ _('hours') }}
</span>
</div>
</div>
</div>
<!-- Time Entries Table -->
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th style="width: 5%">{{ _('Select') }}</th>
<th style="width: 15%">{{ _('Date') }}</th>
<th style="width: 20%">{{ _('User') }}</th>
<th style="width: 25%">{{ _('Task') }}</th>
<th style="width: 15%">{{ _('Duration') }}</th>
<th style="width: 20%">{{ _('Notes') }}</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr class="time-entry-row" data-duration="{{ entry.duration_hours }}" data-task="{{ entry.task.name if entry.task else 'Project: ' + entry.project.name }}">
<td>
<div class="form-check">
<input class="form-check-input time-entry-checkbox" type="checkbox"
name="time_entries[]" value="{{ entry.id }}"
data-duration="{{ entry.duration_hours }}"
data-task="{{ entry.task.name if entry.task else 'Project: ' + entry.project.name }}">
</div>
</td>
<td>
<div class="date-info">
<div class="date-value">{{ entry.start_time.strftime('%b %d') }}</div>
<small class="text-muted">{{ entry.start_time.strftime('%Y') }}</small>
</div>
</td>
<td>
<div class="user-info">
<div class="user-name">{{ entry.user.display_name }}</div>
<small class="text-muted">{{ entry.start_time.strftime('%I:%M %p') }}</small>
</div>
</td>
<td>
{% if entry.task %}
<span class="task-badge">{{ entry.task.name }}</span>
{% else %}
<span class="project-badge">{{ entry.project.name }}</span>
{% endif %}
</td>
<td>
<span class="duration-badge">{{ "%.2f"|format(entry.duration_hours) }}h</span>
</td>
<td>
{% if entry.notes %}
<div class="notes-content">
<div class="notes-text">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</div>
{% if entry.notes|length > 50 %}
<small class="text-muted">{{ _('Click to view full notes') }}</small>
{% endif %}
</div>
{% else %}
<span class="text-muted">{{ _('No notes') }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Summary Alert -->
<div class="alert alert-info mt-4">
<div class="d-flex align-items-center">
<i class="fas fa-info-circle fa-lg me-3"></i>
<div>
<strong>{{ _('Summary:') }}</strong>
<span id="selectedCountText">0</span> {{ _('entries selected,') }}
<span id="totalHoursText">0.00</span> {{ _('hours total') }}
<div class="mt-2">
<small class="text-muted">
{{ _("Selected entries will be converted to invoice line items with the project's hourly rate.") }}
</small>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex justify-content-between mt-4">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary btn-lg" id="generateBtn" disabled>
<i class="fas fa-magic me-2"></i> {{ _('Generate Invoice Items') }}
</button>
</div>
</form>
{% 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 time entries found') }}</h5>
<p class="text-muted mb-4">
{{ _('There are no time entries available for this project to generate invoice items from.') }}
</p>
<div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {{ _('Back to Invoice') }}
</a>
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> {{ _('Add Time Entry') }}
</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Quick Actions Card -->
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-light py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-bolt me-2"></i>{{ _('Quick Actions') }}
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" onclick="selectRecentEntries()">
<i class="fas fa-calendar-week me-2"></i> {{ _('Last 7 Days') }}
</button>
<button type="button" class="btn btn-outline-primary" onclick="selectThisMonth()">
<i class="fas fa-calendar-alt me-2"></i> {{ _('This Month') }}
</button>
<button type="button" class="btn btn-outline-primary" onclick="selectHighValueEntries()">
<i class="fas fa-star me-2"></i> {{ _('High Value Tasks') }}
</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearSelection()">
<i class="fas fa-times me-2"></i> {{ _('Clear Selection') }}
</button>
</div>
</div>
</div>
<!-- Tips Card -->
<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-lightbulb me-2"></i>{{ _('Tips') }}
</h6>
</div>
<div class="card-body">
<div class="tip-item mb-3">
<div class="tip-icon text-info">
<i class="fas fa-clock"></i>
</div>
<div class="tip-content">
<strong>{{ _('Smart Selection') }}</strong>
<p class="small text-muted mb-0">
{{ _('Use quick actions to select time entries by date ranges or task types') }}
</p>
</div>
</div>
<div class="tip-item mb-3">
<div class="tip-icon text-success">
<i class="fas fa-calculator"></i>
</div>
<div class="tip-content">
<strong>{{ _('Automatic Calculation') }}</strong>
<p class="small text-muted mb-0">
{{ _("Line items are automatically calculated using the project's hourly rate") }}
</p>
</div>
</div>
<div class="tip-item">
<div class="tip-icon text-warning">
<i class="fas fa-edit"></i>
</div>
<div class="tip-content">
<strong>{{ _('Review & Edit') }}</strong>
<p class="small text-muted mb-0">
{{ _('You can edit generated items before finalizing the invoice') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.summary-item {
text-align: center;
padding: 16px;
}
.summary-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.selection-controls {
background: var(--light-color);
padding: 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.selection-summary .badge {
font-size: 14px;
padding: 8px 12px;
}
.time-entry-row {
transition: all 0.2s ease;
}
.time-entry-row:hover {
background-color: var(--light-color);
}
.time-entry-row.selected {
background-color: rgba(59, 130, 246, 0.1);
border-left: 4px solid var(--primary-color);
}
.date-info .date-value {
font-weight: 600;
color: var(--text-primary);
}
.user-info .user-name {
font-weight: 600;
color: var(--text-primary);
}
.task-badge {
background: var(--info-color);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.project-badge {
background: var(--secondary-color);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.duration-badge {
background: var(--primary-color);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.notes-content .notes-text {
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.tip-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.tip-icon {
font-size: 18px;
margin-top: 2px;
}
.tip-content strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
.empty-state {
padding: 2rem;
}
.empty-state i {
opacity: 0.5;
}
@media (max-width: 768px) {
.selection-controls {
flex-direction: column;
gap: 16px;
}
.selection-summary {
display: flex;
flex-direction: column;
gap: 8px;
}
.table-responsive {
font-size: 14px;
}
.btn-group {
flex-direction: column;
width: 100%;
}
.btn-group .btn {
margin-bottom: 8px;
border-radius: 6px !important;
}
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize selection summary
updateSelectionSummary();
// Hover effects
document.querySelectorAll('.time-entry-row').forEach(row => {
row.addEventListener('mouseenter', () => row.classList.add('table-active'));
row.addEventListener('mouseleave', () => row.classList.remove('table-active'));
});
// Checkbox change handlers
document.querySelectorAll('.time-entry-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
updateRowSelection(this);
updateSelectionSummary();
});
});
// Form validation
const form = document.getElementById('timeEntriesForm');
if (form) {
form.addEventListener('submit', function(e) {
const selectedCount = document.querySelectorAll('.time-entry-checkbox:checked').length;
if (selectedCount === 0) {
e.preventDefault();
showAlert('{{ _('Please select at least one time entry to generate invoice items.') }}', 'warning');
}
});
}
});
function toggleAllEntries() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.time-entry-checkbox');
const rows = document.querySelectorAll('.time-entry-row');
const checked = !!selectAll.checked;
checkboxes.forEach(cb => { cb.checked = checked; });
rows.forEach(row => { row.classList.toggle('selected', checked); });
updateSelectionSummary();
}
function updateRowSelection(checkbox) {
const row = checkbox.closest('.time-entry-row');
if (row) {
row.classList.toggle('selected', checkbox.checked);
}
}
function updateSelectionSummary() {
const selectedCheckboxes = document.querySelectorAll('.time-entry-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
let totalHours = 0;
selectedCheckboxes.forEach(cb => {
totalHours += parseFloat(cb.getAttribute('data-duration') || '0');
});
const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
setText('selectedCount', selectedCount);
setText('totalHours', totalHours.toFixed(2));
setText('selectedCountText', selectedCount);
setText('totalHoursText', totalHours.toFixed(2));
const generateBtn = document.getElementById('generateBtn');
if (generateBtn) generateBtn.disabled = selectedCount === 0;
}
function selectRecentEntries() {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const currentYear = new Date().getFullYear();
document.querySelectorAll('.time-entry-checkbox').forEach(cb => {
const row = cb.closest('.time-entry-row');
const dateTextEl = row.querySelector('.date-value');
const dateText = dateTextEl ? dateTextEl.textContent : '';
const date = new Date(`${dateText} ${currentYear}`);
if (!isNaN(date.getTime()) && date >= sevenDaysAgo) {
cb.checked = true;
row.classList.add('selected');
}
});
const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelectionSummary();
}
function selectThisMonth() {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
document.querySelectorAll('.time-entry-checkbox').forEach(cb => {
const row = cb.closest('.time-entry-row');
const dateText = row.querySelector('.date-value')?.textContent || '';
const date = new Date(`${dateText} ${currentYear}`);
if (!isNaN(date.getTime()) && date.getMonth() === currentMonth && date.getFullYear() === currentYear) {
cb.checked = true;
row.classList.add('selected');
}
});
const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelectionSummary();
}
function selectHighValueEntries() {
document.querySelectorAll('.time-entry-checkbox').forEach(cb => {
const duration = parseFloat(cb.getAttribute('data-duration') || '0');
if (duration > 2) {
cb.checked = true;
const row = cb.closest('.time-entry-row');
if (row) row.classList.add('selected');
}
});
const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelectionSummary();
}
function clearSelection() {
document.querySelectorAll('.time-entry-checkbox').forEach(cb => { cb.checked = false; });
document.querySelectorAll('.time-entry-row').forEach(row => row.classList.remove('selected'));
const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelectionSummary();
}
function showAlert(message, type) {
const container = document.querySelector('.container-fluid');
if (!container) return;
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
container.prepend(wrapper.firstElementChild);
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) alert.remove();
}, 5000);
}
</script>
{% endblock %}

View File

@@ -1,412 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Invoices') }} - TimeTracker{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('invoices.create_invoice') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> {{ _('Create Invoice') }}
</a>
{% endset %}
{{ page_header('fas fa-file-invoice-dollar', _('Invoices'), _('Billing overview') ~ ' • ' ~ summary.total_invoices ~ ' ' ~ _('total'), actions) }}
</div>
</div>
<!-- Enhanced Summary Cards -->
<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-file-invoice"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Invoices') }}</div>
<div class="summary-value">{{ summary.total_invoices }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Amount') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.total_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Outstanding') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.outstanding_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-exclamation-triangle"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Overdue') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.overdue_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Invoices Table -->
<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 flex-wrap gap-2">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>{{ _('All Invoices') }}
</h6>
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group btn-group-sm me-2" role="group" aria-label="{{ _('Filter status') }}">
<button type="button" class="btn btn-outline-secondary" onclick="filterByStatus('all')">{{ _('All') }}</button>
<button type="button" class="btn btn-outline-secondary" onclick="filterByStatus('draft')">{{ _('Draft') }}</button>
<button type="button" class="btn btn-outline-secondary" onclick="filterByStatus('sent')">{{ _('Sent') }}</button>
<button type="button" class="btn btn-outline-secondary" onclick="filterByStatus('paid')">{{ _('Paid') }}</button>
<button type="button" class="btn btn-outline-secondary" onclick="filterByStatus('overdue')">{{ _('Overdue') }}</button>
</div>
<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 invoices...') }}">
</div>
</div>
</div>
</div>
<div class="card-body p-0">
{% if invoices %}
<div class="table-responsive">
<table class="table table-hover mb-0" id="invoicesTable">
<thead class="table-light">
<tr>
<th class="border-0">{{ _('Invoice #') }}</th>
<th class="border-0">{{ _('Client') }}</th>
<th class="border-0">{{ _('Project') }}</th>
<th class="border-0">{{ _('Issue Date') }}</th>
<th class="border-0">{{ _('Due Date') }}</th>
<th class="border-0">{{ _('Amount') }}</th>
<th class="border-0">{{ _('Status') }}</th>
<th class="border-0">{{ _('Payment') }}</th>
<th class="border-0 text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr class="invoice-row" data-status="{{ invoice.status }}">
<td class="align-middle" data-label="{{ _('Invoice #') }}">
<div class="d-flex align-items-center">
<div class="invoice-number-badge me-2">
{{ invoice.invoice_number }}
</div>
</div>
</td>
<td class="align-middle" data-label="{{ _('Client') }}">
<div class="client-info">
<div class="client-name">{{ invoice.client_name }}</div>
{% if invoice.client_email %}
<small class="text-muted">{{ invoice.client_email }}</small>
{% endif %}
</div>
</td>
<td class="align-middle" data-label="{{ _('Project') }}">
<span class="project-badge">{{ invoice.project.name }}</span>
</td>
<td class="align-middle" data-label="{{ _('Issue Date') }}">
<div class="date-info">
<div class="date-value">{{ invoice.issue_date.strftime('%b %d, %Y') }}</div>
<small class="text-muted">{{ invoice.issue_date.strftime('%Y-%m-%d') }}</small>
</div>
</td>
<td class="align-middle" data-label="{{ _('Due Date') }}">
{% if invoice.is_overdue %}
<div class="due-date overdue">
<div class="date-value text-danger">
{{ invoice.due_date.strftime('%b %d, %Y') }}
</div>
<small class="text-danger">
<i class="fas fa-exclamation-circle me-1"></i>
{{ invoice.days_overdue }} {{ _('days overdue') }}
</small>
</div>
{% else %}
<div class="due-date">
<div class="date-value">{{ invoice.due_date.strftime('%b %d, %Y') }}</div>
<small class="text-muted">{{ invoice.due_date.strftime('%Y-%m-%d') }}</small>
</div>
{% endif %}
</td>
<td class="align-middle" data-label="{{ _('Amount') }}">
<div class="amount-info">
<div class="amount-value">{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}</div>
</div>
</td>
<td class="align-middle" data-label="{{ _('Status') }}">
{% set status_config = {
'draft': {'color': 'secondary', 'icon': 'edit', 'bg': 'bg-secondary', 'label': _('Draft')},
'sent': {'color': 'info', 'icon': 'paper-plane', 'bg': 'bg-info', 'label': _('Sent')},
'paid': {'color': 'success', 'icon': 'check-circle', 'bg': 'bg-success', 'label': _('Paid')},
'overdue': {'color': 'danger', 'icon': 'exclamation-triangle', 'bg': 'bg-danger', 'label': _('Overdue')},
'cancelled': {'color': 'dark', 'icon': 'times-circle', 'bg': 'bg-dark', 'label': _('Cancelled')}
} %}
{% set config = status_config.get(invoice.status, status_config.draft) %}
<span class="status-badge {{ config.bg }} text-white">
<i class="fas fa-{{ config.icon }} me-1"></i>
{{ config.label }}
</span>
</td>
<td class="align-middle" data-label="{{ _('Payment') }}">
<div class="payment-status-info">
{% if invoice.payment_status == 'unpaid' %}
<span class="payment-badge bg-danger text-white">
<i class="fas fa-times-circle me-1"></i>{{ _('Unpaid') }}
</span>
{% elif invoice.payment_status == 'partially_paid' %}
<span class="payment-badge bg-warning text-white">
<i class="fas fa-clock me-1"></i>{{ _('Partial') }}
</span>
<div class="payment-progress mt-1">
<div class="progress" style="height: 4px;">
<div class="progress-bar bg-warning"
style="width: {{ invoice.payment_percentage }}%"></div>
</div>
<small class="text-muted">{{ "%.0f"|format(invoice.payment_percentage) }}%</small>
</div>
{% elif invoice.payment_status == 'fully_paid' %}
<span class="payment-badge bg-success text-white">
<i class="fas fa-check-circle me-1"></i>{{ _('Paid') }}
</span>
{% if invoice.payment_date %}
<div class="payment-date">
<small class="text-muted">{{ invoice.payment_date.strftime('%b %d') }}</small>
</div>
{% endif %}
{% elif invoice.payment_status == 'overpaid' %}
<span class="payment-badge bg-info text-white">
<i class="fas fa-plus-circle me-1"></i>{{ _('Overpaid') }}
</span>
{% endif %}
</div>
</td>
<td class="align-middle text-center" data-label="{{ _('Actions') }}">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}"
class="btn btn-sm btn-action btn-action--view"
title="{{ _('View Invoice') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}"
class="btn btn-sm btn-action btn-action--edit"
title="{{ _('Edit Invoice') }}">
<i class="fas fa-edit"></i>
</a>
<button type="button"
class="btn btn-sm btn-action btn-action--more dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
title="{{ _('More actions') }}">
<i class="fas fa-ellipsis-h"></i>
<span class="visually-hidden">{{ _('More actions') }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" data-bs-auto-close="true">
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.export_invoice_csv', invoice_id=invoice.id) }}">
<i class="fas fa-download me-2"></i> {{ _('Export CSV') }}
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.duplicate_invoice', invoice_id=invoice.id) }}">
<i class="fas fa-copy me-2"></i> {{ _('Duplicate') }}
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}">
<i class="fas fa-clock me-2"></i> {{ _('Generate from Time') }}
</a>
</li>
{% if invoice.payment_status != 'fully_paid' %}
<li>
<a class="dropdown-item text-success"
href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}">
<i class="fas fa-money-bill-wave me-2"></i> {{ _('Record Payment') }}
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST"
action="{{ url_for('invoices.delete_invoice', invoice_id=invoice.id) }}"
class="d-inline"
data-confirm="{{ _('Are you sure you want to delete this invoice? This action cannot be undone.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item text-danger">
<i class="fas fa-trash me-2"></i> {{ _('Delete') }}
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-file-invoice fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No invoices found') }}</h5>
<p class="text-muted mb-4">{{ _('Create your first invoice to get started with billing.') }}</p>
<a href="{{ url_for('invoices.create_invoice') }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus me-2"></i> {{ _('Create Your First Invoice') }}
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
<script type="application/json" id="i18n-json-invoices-list">
{
"search_invoices": {{ _('Search invoices:')|tojson }},
"length_menu": {{ _('Show _MENU_ invoices per page')|tojson }},
"info": {{ _('Showing _START_ to _END_ of _TOTAL_ invoices')|tojson }},
"confirm_delete_prefix": {{ _('Are you sure you want to delete invoice')|tojson }},
"confirm_delete_suffix": {{ _('This action cannot be undone.')|tojson }}
}
</script>
<script>
var i18nList = (function(){
try { var el = document.getElementById('i18n-json-invoices-list'); return el ? JSON.parse(el.textContent) : {}; }
catch(e) { return {}; }
})();
</script>
<script>
$(document).ready(function() {
// Initialize DataTable with enhanced features
const table = $('#invoicesTable').DataTable({
order: [[3, 'desc']], // Sort by issue date descending
pageLength: 25,
language: {
search: i18nList.search_invoices || 'Search invoices:',
lengthMenu: i18nList.length_menu || 'Show _MENU_ invoices per page',
info: i18nList.info || 'Showing _START_ to _END_ of _TOTAL_ invoices'
},
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 } // Disable sorting on actions column
]
});
// Custom search functionality
$('#searchInput').on('keyup', function() {
table.search(this.value).draw();
});
// Add row hover effects
$('#invoicesTable tbody tr').hover(
function() {
$(this).addClass('table-active');
},
function() {
$(this).removeClass('table-active');
}
);
});
// Filter by status
function filterByStatus(status) {
const table = $('#invoicesTable').DataTable();
if (status === 'all') {
table.column(6).search('').draw();
} else {
table.column(6).search(status).draw();
}
// Update active filter button
$('.dropdown-item').removeClass('active');
$(`[onclick="filterByStatus('${status}')"]`).addClass('active');
}
// Enhanced confirmation for delete
function confirmDelete(invoiceNumber) {
const prefix = i18nList.confirm_delete_prefix || 'Are you sure you want to delete invoice';
const suffix = i18nList.confirm_delete_suffix || 'This action cannot be undone.';
return window.showConfirm(`${prefix} ${invoiceNumber}? ${suffix}`);
}
// Quick status update (if implemented in backend)
function updateStatus(invoiceId, newStatus) {
// This would typically make an AJAX call to update the status
console.log(`Updating invoice ${invoiceId} to status: ${newStatus}`);
}
// Handle generic data-confirm forms using modal
$(document).on('submit', 'form[data-confirm]', function(e) {
const form = this;
const message = $(form).attr('data-confirm');
e.preventDefault();
window.showConfirm(message).then(function(ok){ if (ok) form.submit(); });
});
</script>
{% endblock %}

View File

@@ -1,230 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Record Payment') }} - {{ invoice.invoice_number }} - TimeTracker{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-outline-secondary me-3">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="h3 mb-0">
<i class="fas fa-money-bill-wave text-success"></i>
{{ _('Record Payment') }}
</h1>
</div>
</div>
<div class="row">
<!-- Payment Form -->
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="fas fa-credit-card me-2"></i>
{{ _('Payment Details') }}
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="amount" class="form-label">
{{ _('Payment Amount') }} <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-dollar-sign"></i>
</span>
<input type="number"
class="form-control"
id="amount"
name="amount"
step="0.01"
min="0.01"
max="{{ invoice.outstanding_amount }}"
value="{{ invoice.outstanding_amount }}"
required>
</div>
<div class="form-text">
{{ _('Outstanding amount:') }} {{ "%.2f"|format(invoice.outstanding_amount) }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="payment_date" class="form-label">
{{ _('Payment Date') }}
</label>
<input type="date"
class="form-control"
id="payment_date"
name="payment_date"
value="{{ today }}">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="payment_method" class="form-label">
{{ _('Payment Method') }}
</label>
<select class="form-select" id="payment_method" name="payment_method">
<option value="">{{ _('Select payment method') }}</option>
<option value="cash">{{ _('Cash') }}</option>
<option value="check">{{ _('Check') }}</option>
<option value="bank_transfer">{{ _('Bank Transfer') }}</option>
<option value="credit_card">{{ _('Credit Card') }}</option>
<option value="debit_card">{{ _('Debit Card') }}</option>
<option value="paypal">{{ _('PayPal') }}</option>
<option value="stripe">{{ _('Stripe') }}</option>
<option value="wire_transfer">{{ _('Wire Transfer') }}</option>
<option value="other">{{ _('Other') }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="payment_reference" class="form-label">
{{ _('Payment Reference') }}
</label>
<input type="text"
class="form-control"
id="payment_reference"
name="payment_reference"
placeholder="{{ _('Transaction ID, check number, etc.') }}">
</div>
</div>
</div>
<div class="mb-3">
<label for="payment_notes" class="form-label">
{{ _('Payment Notes') }}
</label>
<textarea class="form-control"
id="payment_notes"
name="payment_notes"
rows="3"
placeholder="{{ _('Additional notes about this payment...') }}"></textarea>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-2"></i>{{ _('Record Payment') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Invoice Summary -->
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-file-invoice-dollar me-2"></i>
{{ _('Invoice Summary') }}
</h5>
</div>
<div class="card-body">
<div class="invoice-summary">
<div class="summary-row">
<span class="label">{{ _('Invoice Number:') }}</span>
<span class="value">{{ invoice.invoice_number }}</span>
</div>
<div class="summary-row">
<span class="label">{{ _('Client:') }}</span>
<span class="value">{{ invoice.client_name }}</span>
</div>
<div class="summary-row">
<span class="label">{{ _('Total Amount:') }}</span>
<span class="value font-weight-bold">{{ "%.2f"|format(invoice.total_amount) }}</span>
</div>
<div class="summary-row">
<span class="label">{{ _('Amount Paid:') }}</span>
<span class="value text-success">{{ "%.2f"|format(invoice.amount_paid or 0) }}</span>
</div>
<hr>
<div class="summary-row">
<span class="label">{{ _('Outstanding:') }}</span>
<span class="value font-weight-bold text-danger">{{ "%.2f"|format(invoice.outstanding_amount) }}</span>
</div>
</div>
<!-- Payment Status -->
<div class="mt-3">
<div class="payment-status-badge">
{% if invoice.payment_status == 'unpaid' %}
<span class="badge bg-danger">
<i class="fas fa-exclamation-circle me-1"></i>{{ _('Unpaid') }}
</span>
{% elif invoice.payment_status == 'partially_paid' %}
<span class="badge bg-warning">
<i class="fas fa-clock me-1"></i>{{ _('Partially Paid') }}
</span>
{% elif invoice.payment_status == 'fully_paid' %}
<span class="badge bg-success">
<i class="fas fa-check-circle me-1"></i>{{ _('Fully Paid') }}
</span>
{% elif invoice.payment_status == 'overpaid' %}
<span class="badge bg-info">
<i class="fas fa-plus-circle me-1"></i>{{ _('Overpaid') }}
</span>
{% endif %}
</div>
{% if invoice.payment_percentage > 0 %}
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar bg-success"
role="progressbar"
style="width: {{ invoice.payment_percentage }}%"
aria-valuenow="{{ invoice.payment_percentage }}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ "%.1f"|format(invoice.payment_percentage) }}% {{ _('paid') }}</small>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.invoice-summary .summary-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.invoice-summary .label {
font-weight: 500;
color: #6c757d;
}
.invoice-summary .value {
font-weight: 600;
}
.payment-status-badge .badge {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
</style>
{% endblock %}

View File

@@ -1,774 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Invoice') }} {{ invoice.invoice_number }} - TimeTracker{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Enhanced Header with Status Badge -->
<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-file-invoice-dollar text-primary"></i>
{{ _('Invoice') }} {{ invoice.invoice_number }}
</h1>
{% set status_config = {
'draft': {'color': 'secondary', 'icon': 'edit', 'bg': 'bg-secondary', 'label': _('Draft')},
'sent': {'color': 'info', 'icon': 'paper-plane', 'bg': 'bg-info', 'label': _('Sent')},
'paid': {'color': 'success', 'icon': 'check-circle', 'bg': 'bg-success', 'label': _('Paid')},
'overdue': {'color': 'danger', 'icon': 'exclamation-triangle', 'bg': 'bg-danger', 'label': _('Overdue')},
'cancelled': {'color': 'dark', 'icon': 'times-circle', 'bg': 'bg-dark', 'label': _('Cancelled')}
} %}
{% set config = status_config.get(invoice.status, status_config.draft) %}
<span class="status-badge-large {{ config.bg }} text-white">
<i class="fas fa-{{ config.icon }} me-2"></i>
{{ config.label }}
</span>
</div>
<div class="btn-group" role="group">
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-secondary">
<i class="fas fa-edit me-1"></i> {{ _('Edit') }}
</a>
{% if invoice.payment_status != 'fully_paid' %}
<a href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}"
class="btn btn-success">
<i class="fas fa-money-bill-wave me-1"></i> {{ _('Record Payment') }}
</a>
{% endif %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-info dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">{{ _('Export options') }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{{ url_for('invoices.export_invoice_csv', invoice_id=invoice.id) }}">
<i class="fas fa-download me-2"></i> {{ _('Export CSV') }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}">
<i class="fas fa-file-pdf me-2"></i> {{ _('Export PDF') }}
</a>
</li>
</ul>
</div>
<a href="{{ url_for('invoices.duplicate_invoice', invoice_id=invoice.id) }}" class="btn btn-outline-primary">
<i class="fas fa-copy me-1"></i> {{ _('Duplicate') }}
</a>
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {{ _('Back') }}
</a>
</div>
</div>
<!-- Invoice Overview Cards -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-user me-2"></i>{{ _('Bill To') }}
</h6>
<div class="client-details">
<div class="client-name">{{ invoice.client_name }}</div>
{% if invoice.client_email %}
<div class="client-email">
<i class="fas fa-envelope me-1 text-muted"></i>
{{ invoice.client_email }}
</div>
{% endif %}
{% if invoice.client_address %}
<div class="client-address">
<i class="fas fa-map-marker-alt me-1 text-muted"></i>
{{ invoice.client_address|nl2br }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-info-circle me-2"></i>{{ _('Invoice Details') }}
</h6>
<div class="invoice-details">
<div class="detail-row">
<span class="detail-label">{{ _('Invoice #:') }}</span>
<span class="detail-value">{{ invoice.invoice_number }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _('Project:') }}</span>
<span class="detail-value">{{ invoice.project.name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _('Issue Date:') }}</span>
<span class="detail-value">{{ invoice.issue_date.strftime('%B %d, %Y') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _('Due Date:') }}</span>
<span class="detail-value {% if invoice.is_overdue %}text-danger{% endif %}">
{{ invoice.due_date.strftime('%B %d, %Y') }}
{% if invoice.is_overdue %}
<span class="badge bg-danger ms-2">
<i class="fas fa-exclamation-triangle me-1"></i>
{{ invoice.days_overdue }} {{ _('days overdue') }}
</span>
{% endif %}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<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-cog me-2"></i>{{ _('Status & Actions') }}
</h6>
</div>
<div class="card-body">
<div class="status-actions">
{% if invoice.status == 'draft' %}
<div class="mb-3">
<a href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}"
class="btn btn-primary w-100">
<i class="fas fa-clock me-2"></i> {{ _('Generate from Time') }}
</a>
</div>
{% endif %}
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary w-100"
data-bs-toggle="modal" data-bs-target="#statusModal">
<i class="fas fa-edit me-2"></i> {{ _('Change Status') }}
</button>
</div>
<div class="info-section">
<div class="info-item">
<div class="info-label">{{ _('Created by') }}</div>
<div class="info-value">{{ invoice.creator.display_name }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ _('Created') }}</div>
<div class="info-value">{{ invoice.created_at.strftime('%B %d, %Y') }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ _('Last Modified') }}</div>
<div class="info-value">{{ invoice.updated_at.strftime('%B %d, %Y') }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% set tax_present = invoice.tax_rate > 0 %}
<!-- Totals Summary -->
<div class="row mb-4">
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-primary text-white me-3"><i class="fas fa-coins"></i></div>
<div>
<div class="summary-label">{{ _('Subtotal') }}</div>
<div class="summary-value">{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
{% if tax_present %}
<div class="col-md-4">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-warning text-white me-3"><i class="fas fa-percent"></i></div>
<div>
<div class="summary-label">{{ _('Tax') }} ({{ "%.2f"|format(invoice.tax_rate) }}%)</div>
<div class="summary-value">{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-success text-white me-3"><i class="fas fa-sack-dollar"></i></div>
<div>
<div class="summary-label">{{ _('Total Amount') }}</div>
<div class="summary-value text-success">{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Payment Status Summary -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-success text-white me-3"><i class="fas fa-money-bill-wave"></i></div>
<div>
<div class="summary-label">{{ _('Amount Paid') }}</div>
<div class="summary-value text-success">{{ "%.2f"|format(invoice.amount_paid or 0) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-warning text-white me-3"><i class="fas fa-exclamation-circle"></i></div>
<div>
<div class="summary-label">{{ _('Outstanding') }}</div>
<div class="summary-value text-warning">{{ "%.2f"|format(invoice.outstanding_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
{% if invoice.payment_status == 'unpaid' %}
<div class="summary-icon bg-danger text-white me-3"><i class="fas fa-times-circle"></i></div>
{% elif invoice.payment_status == 'partially_paid' %}
<div class="summary-icon bg-warning text-white me-3"><i class="fas fa-clock"></i></div>
{% elif invoice.payment_status == 'fully_paid' %}
<div class="summary-icon bg-success text-white me-3"><i class="fas fa-check-circle"></i></div>
{% elif invoice.payment_status == 'overpaid' %}
<div class="summary-icon bg-info text-white me-3"><i class="fas fa-plus-circle"></i></div>
{% endif %}
<div>
<div class="summary-label">{{ _('Payment Status') }}</div>
<div class="summary-value">
{% if invoice.payment_status == 'unpaid' %}
<span class="text-danger">{{ _('Unpaid') }}</span>
{% elif invoice.payment_status == 'partially_paid' %}
<span class="text-warning">{{ _('Partially Paid') }}</span>
{% elif invoice.payment_status == 'fully_paid' %}
<span class="text-success">{{ _('Fully Paid') }}</span>
{% elif invoice.payment_status == 'overpaid' %}
<span class="text-info">{{ _('Overpaid') }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body">
<div class="summary-label mb-2">{{ _('Payment Progress') }}</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-success"
role="progressbar"
style="width: {{ invoice.payment_percentage }}%"
aria-valuenow="{{ invoice.payment_percentage }}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ "%.1f"|format(invoice.payment_percentage) }}% {{ _('paid') }}</small>
</div>
</div>
</div>
</div>
<!-- Payment Details -->
{% if invoice.payment_date or invoice.payment_method or invoice.payment_reference or invoice.payment_notes %}
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-receipt me-2"></i>
{{ _('Payment Details') }}
</h5>
</div>
<div class="card-body">
<div class="row">
{% if invoice.payment_date %}
<div class="col-md-3">
<div class="payment-detail">
<span class="detail-label">{{ _('Payment Date:') }}</span>
<span class="detail-value">{{ invoice.payment_date.strftime('%B %d, %Y') }}</span>
</div>
</div>
{% endif %}
{% if invoice.payment_method %}
<div class="col-md-3">
<div class="payment-detail">
<span class="detail-label">{{ _('Payment Method:') }}</span>
<span class="detail-value">
{% if invoice.payment_method == 'cash' %}{{ _('Cash') }}
{% elif invoice.payment_method == 'check' %}{{ _('Check') }}
{% elif invoice.payment_method == 'bank_transfer' %}{{ _('Bank Transfer') }}
{% elif invoice.payment_method == 'credit_card' %}{{ _('Credit Card') }}
{% elif invoice.payment_method == 'debit_card' %}{{ _('Debit Card') }}
{% elif invoice.payment_method == 'paypal' %}{{ _('PayPal') }}
{% elif invoice.payment_method == 'stripe' %}{{ _('Stripe') }}
{% elif invoice.payment_method == 'wire_transfer' %}{{ _('Wire Transfer') }}
{% else %}{{ invoice.payment_method|title }}{% endif %}
</span>
</div>
</div>
{% endif %}
{% if invoice.payment_reference %}
<div class="col-md-3">
<div class="payment-detail">
<span class="detail-label">{{ _('Payment Reference:') }}</span>
<span class="detail-value">{{ invoice.payment_reference }}</span>
</div>
</div>
{% endif %}
</div>
{% if invoice.payment_notes %}
<div class="row mt-3">
<div class="col-12">
<div class="payment-detail">
<span class="detail-label">{{ _('Payment Notes:') }}</span>
<div class="detail-value">{{ invoice.payment_notes|nl2br }}</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Enhanced Invoice Items -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary d-flex align-items-center">
<i class="fas fa-list me-2"></i>{{ _('Invoice Items') }}
<span class="badge badge-soft-secondary ms-2">{{ invoice.items.count() }} {{ _('items') }}</span>
</h6>
{% if invoice.status == 'draft' %}
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit me-2"></i> {{ _('Edit Items') }}
</a>
{% endif %}
</div>
<div class="card-body p-0">
{% if invoice.items.count() > 0 %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0 ps-3">{{ _('Description') }}</th>
<th class="border-0 text-center">{{ _('Quantity (Hours)') }}</th>
<th class="border-0 text-end">{{ _('Unit Price') }}</th>
<th class="border-0 text-end pe-3">{{ _('Total Amount') }}</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr class="item-row">
<td class="ps-3">
<div class="item-description">
<div class="item-text">{{ item.description }}</div>
{% if item.time_entry_ids %}
<div class="item-meta">
<i class="fas fa-clock text-info me-1"></i>
{{ _('Generated from') }} {{ item.time_entry_ids.split(',')|length }} {{ _('time entries') }}
</div>
{% endif %}
</div>
</td>
<td class="text-center">
<span class="quantity-badge">{{ "%.2f"|format(item.quantity) }}h</span>
</td>
<td class="text-end">
<span class="unit-price">{{ "%.2f"|format(item.unit_price) }} {{ currency }}</span>
</td>
<td class="text-end pe-3">
<span class="item-total">{{ "%.2f"|format(item.total_amount) }} {{ currency }}</span>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end ps-3">
<strong>{{ _('Subtotal:') }}</strong>
</td>
<td class="text-end pe-3">
<strong>{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}</strong>
</td>
</tr>
{% if invoice.tax_rate > 0 %}
<tr>
<td colspan="3" class="text-end ps-3">
<strong>{{ _('Tax') }} ({{ "%.2f"|format(invoice.tax_rate) }}%):</strong>
</td>
<td class="text-end pe-3">
<strong>{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}</strong>
</td>
</tr>
{% endif %}
<tr class="table-primary">
<td colspan="3" class="text-end ps-3">
<strong class="fs-5">{{ _('Total Amount:') }}</strong>
</td>
<td class="text-end pe-3">
<strong class="fs-5">{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-list fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No invoice items') }}</h5>
<p class="text-muted mb-4">{{ _('Add items to this invoice to generate totals.') }}</p>
{% if invoice.status == 'draft' %}
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> {{ _('Add Items') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Additional Information -->
{% if invoice.notes or invoice.terms %}
<div class="row mb-4">
<div class="col-12">
<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-text me-2"></i>{{ _('Additional Information') }}
</h6>
</div>
<div class="card-body">
<div class="row">
{% if invoice.notes %}
<div class="col-md-6">
<h6 class="text-primary mb-3">{{ _('Notes') }}</h6>
<div class="content-box">
{{ invoice.notes|nl2br }}
</div>
</div>
{% endif %}
{% if invoice.terms %}
<div class="col-md-6">
<h6 class="text-primary mb-3">{{ _('Terms & Conditions') }}</h6>
<div class="content-box">
{{ invoice.terms|nl2br }}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Status Change Modal -->
<div class="modal fade" id="statusModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ _('Change Invoice Status') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form method="POST" action="{{ url_for('invoices.update_invoice_status', invoice_id=invoice.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="new_status" class="form-label">{{ _('New Status') }}</label>
<select class="form-select" id="new_status" name="new_status" required>
<option value="draft" {% if invoice.status == 'draft' %}selected{% endif %}>{{ _('Draft') }}</option>
<option value="sent" {% if invoice.status == 'sent' %}selected{% endif %}>{{ _('Sent') }}</option>
<option value="paid" {% if invoice.status == 'paid' %}selected{% endif %}>{{ _('Paid') }}</option>
<option value="cancelled" {% if invoice.status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
</select>
</div>
<div class="mb-3">
<label for="status_notes" class="form-label">{{ _('Notes (Optional)') }}</label>
<textarea class="form-control" id="status_notes" name="status_notes" rows="3"
placeholder="{{ _('Add any notes about this status change...') }}"></textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ _('Update Status') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
<style>
.payment-detail {
margin-bottom: 1rem;
}
.payment-detail .detail-label {
font-weight: 600;
color: #6c757d;
display: block;
margin-bottom: 0.25rem;
}
.payment-detail .detail-value {
font-weight: 500;
color: #495057;
}
.status-badge-large {
padding: 8px 16px;
border-radius: 25px;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.invoice-section {
padding: 16px 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
border-bottom: 2px solid var(--primary-color);
padding-bottom: 8px;
}
.client-details .client-name {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.client-email, .client-address {
color: var(--text-secondary);
margin-bottom: 4px;
font-size: 14px;
}
.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);
}
.status-actions .btn {
margin-bottom: 12px;
}
.info-section {
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.info-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.item-row {
transition: background-color 0.2s ease;
}
.item-row:hover {
background-color: var(--light-color);
}
.item-description .item-text {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.item-description .item-meta {
font-size: 12px;
color: var(--text-muted);
}
.quantity-badge {
background: var(--primary-color);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.unit-price {
font-weight: 600;
color: var(--text-secondary);
}
.item-total {
font-weight: 700;
color: var(--text-primary);
font-size: 16px;
}
.content-box {
background: var(--light-color);
padding: 16px;
border-radius: 8px;
border-left: 4px solid var(--primary-color);
line-height: 1.6;
}
.empty-state {
padding: 2rem;
}
.empty-state i {
opacity: 0.5;
}
.summary-card .summary-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.4px;
}
.summary-card .summary-value {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.summary-card .summary-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
}
@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 %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add hover effects to item rows
const itemRows = document.querySelectorAll('.item-row');
itemRows.forEach(function(row) {
row.addEventListener('mouseenter', function() {
this.classList.add('table-active');
});
row.addEventListener('mouseleave', function() {
this.classList.remove('table-active');
});
});
// Status change confirmation
const statusForm = document.querySelector('#statusModal form');
if (statusForm) {
statusForm.addEventListener('submit', function(e) {
const newStatusField = document.getElementById('new_status');
const newStatus = newStatusField ? newStatusField.value : '';
const currentStatus = '{{ invoice.status }}';
if (newStatus === currentStatus) {
e.preventDefault();
alert('{{ _('The selected status is the same as the current status.') }}');
return false;
}
if (newStatus === 'cancelled') {
if (!confirm('{{ _('Are you sure you want to cancel this invoice? This action cannot be undone.') }}')) {
e.preventDefault();
return false;
}
}
});
}
});
</script>
{% endblock %}

View File

@@ -1,88 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Client') }} - {{ client_name }} - {{ 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">
<h1 class="h3 mb-0">
<i class="fas fa-briefcase text-primary"></i> {{ client_name }}
</h1>
<a href="{{ url_for('projects.list_clients') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
</a>
</div>
</div>
</div>
<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> {{ _('Projects') }} ({{ projects|length }})</h5>
</div>
<div class="card-body">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _('Project') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Total Hours') }}</th>
<th>{{ _('Billable Hours') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">
<strong>{{ project.name }}</strong>
</a>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %}</small>
{% endif %}
</td>
<td>
{% if project.status == 'active' %}
<span class="badge bg-success">{{ _('Active') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('Archived') }}</span>
{% endif %}
</td>
<td><strong>{{ "%.1f"|format(project.total_hours) }}h</strong></td>
<td>
{% if project.billable %}
<span class="text-success">{{ "%.1f"|format(project.total_billable_hours) }}h</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {{ _('View') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h4 class="text-muted">{{ _('No Projects for this Client') }}</h4>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,60 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Clients') }} - {{ 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">
<h1 class="h3 mb-0">
<i class="fas fa-building text-primary"></i> {{ _('Clients') }}
</h1>
</div>
</div>
</div>
<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>
<div class="card-body">
{% if clients %}
<div class="row row-cols-1 row-cols-md-3 g-3">
{% for client in clients %}
<div class="col">
<a class="text-decoration-none" href="{{ url_for('clients.view_client', client_id=client.id) }}">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title mb-0"><i class="fas fa-briefcase me-2 text-primary"></i>{{ client.name }}</h5>
{% if client.description %}
<p class="card-text text-muted small mt-2">{{ client.description[:50] }}{% if client.description|length > 50 %}...{% endif %}</p>
{% endif %}
<div class="mt-2">
<span class="badge bg-primary">{{ client.total_projects }} {{ _('projects') }}</span>
{% if client.default_hourly_rate %}
<span class="badge bg-success ms-1">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}/{{ _('hr') }}</span>
{% endif %}
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-briefcase fa-3x text-muted mb-3"></i>
<h4 class="text-muted">{{ _('No Clients Found') }}</h4>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,246 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if project %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %} {{ _('Project') }} - {{ 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('projects.list_projects') }}">{{ _('Projects') }}</a></li>
<li class="breadcrumb-item active">{% if project %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-project-diagram text-primary"></i>
{% if project %}{{ _('Edit Project') }}{% else %}{{ _('New Project') }}{% endif %}
</h1>
</div>
<div>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Projects') }}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-edit"></i> {{ _('Project Information') }}
</h5>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-8">
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else "")) }}
{% if form.name.errors %}
<div class="invalid-feedback">
{% for error in form.name.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
{{ form.client.label(class="form-label") }}
{{ form.client(class="form-control" + (" is-invalid" if form.client.errors else "")) }}
{% if form.client.errors %}
<div class="invalid-feedback">
{% for error in form.client.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3" + (" is-invalid" if form.description.errors else "")) }}
{% if form.description.errors %}
<div class="invalid-feedback">
{% for error in form.description.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<div class="form-check">
{{ form.billable(class="form-check-input") }}
{{ form.billable.label(class="form-check-label") }}
</div>
{% if form.billable.errors %}
<div class="text-danger small">
{% for error in form.billable.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.status.label(class="form-label") }}
{{ form.status(class="form-select" + (" is-invalid" if form.status.errors else "")) }}
{% if form.status.errors %}
<div class="invalid-feedback">
{% for error in form.status.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.hourly_rate.label(class="form-label") }}
<div class="input-group">
<span class="input-group-text">{{ currency }}</span>
{{ form.hourly_rate(class="form-control" + (" is-invalid" if form.hourly_rate.errors else "")) }}
</div>
{% if form.hourly_rate.errors %}
<div class="invalid-feedback">
{% for error in form.hourly_rate.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ _('Leave empty for non-billable projects') }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.billing_ref.label(class="form-label") }}
{{ form.billing_ref(class="form-control" + (" is-invalid" if form.billing_ref.errors else "")) }}
{% if form.billing_ref.errors %}
<div class="invalid-feedback">
{% for error in form.billing_ref.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">Optional billing reference</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{% if project %}{{ _('Update Project') }}{% else %}{{ _('Create Project') }}{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> {{ _('Help') }}
</h5>
</div>
<div class="card-body">
<h6>{{ _('Project Name') }}</h6>
<p class="text-muted small">{{ _('Choose a descriptive name that clearly identifies the project.') }}</p>
<h6>{{ _('Client') }}</h6>
<p class="text-muted small">{{ _('Optional client name for organization. You can group projects by client.') }}</p>
<h6>{{ _('Description') }}</h6>
<p class="text-muted small">{{ _('Provide details about the project scope, objectives, or any relevant information.') }}</p>
<h6>{{ _('Billable') }}</h6>
<p class="text-muted small">{{ _('Check this if time spent on this project should be tracked for billing purposes.') }}</p>
<h6>{{ _('Hourly Rate') }}</h6>
<p class="text-muted small">{{ _('Set the hourly rate for billable time. Leave empty for non-billable projects.') }}</p>
<h6>{{ _('Billing Reference') }}</h6>
<p class="text-muted small">{{ _('Optional reference number or code for billing systems.') }}</p>
<h6>{{ _('Status') }}</h6>
<p class="text-muted small">{{ _('Active projects can have time tracked. Archived projects are hidden from timers but retain data.') }}</p>
</div>
</div>
{% if project %}
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> {{ _('Current Statistics') }}
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="h5 text-primary">{{ "%.1f"|format(project.total_hours) }}</div>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6 mb-3">
<div class="h5 text-success">{{ "%.1f"|format(project.total_billable_hours) }}</div>
<small class="text-muted">{{ _('Billable Hours') }}</small>
</div>
{% if project.billable and project.hourly_rate %}
<div class="col-12">
<div class="h5 text-success">{{ currency }} {{ "%.2f"|format(project.estimated_cost) }}</div>
<small class="text-muted">{{ _('Estimated Cost') }}</small>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Show/hide hourly rate field based on billable checkbox
document.addEventListener('DOMContentLoaded', function() {
const billableCheckbox = document.getElementById('billable');
const hourlyRateField = document.getElementById('hourly_rate').closest('.mb-3');
function toggleHourlyRate() {
if (billableCheckbox.checked) {
hourlyRateField.style.display = 'block';
} else {
hourlyRateField.style.display = 'none';
document.getElementById('hourly_rate').value = '';
}
}
billableCheckbox.addEventListener('change', toggleHourlyRate);
toggleHourlyRate(); // Initial state
});
</script>
{% endblock %}

View File

@@ -1,744 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Projects') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn-header btn-primary">
<i class="fas fa-plus me-2"></i>{{ _('New Project') }}
</a>
{% endif %}
{% endset %}
{{ page_header('fas fa-project-diagram', _('Projects'), _('Manage all projects') ~ ' • ' ~ (projects|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
{# Summary cards similar to invoices #}
{% set _active = 0 %}
{% set _inactive = 0 %}
{% set _archived = 0 %}
{% set _total_hours = 0 %}
{% for p in projects %}
{% if p.status == 'active' %}{% set _active = _active + 1 %}{% endif %}
{% if p.status == 'inactive' %}{% set _inactive = _inactive + 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-warning bg-opacity-10 text-warning"><i class="fas fa-pause-circle"></i></div>
<div class="ms-3">
<div class="summary-label">{{ _('Inactive') }}</div>
<div class="summary-value">{{ _inactive }}</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>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card mobile-card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-filter me-2 text-primary"></i>{{ _('Filters') }}
</h6>
<button type="button" class="btn btn-sm" id="toggleFilters" onclick="toggleFilterVisibility()" title="{{ _('Toggle Filters') }}">
<i class="fas fa-chevron-up" id="filterToggleIcon"></i>
</button>
</div>
</div>
<div class="card-body" id="filterBody">
<form method="GET" class="row g-3 mobile-form">
<div class="col-12 col-md-4 mobile-form-group">
<label for="status" class="form-label">{{ _('Status') }}</label>
<select class="form-select touch-target" id="status" name="status">
<option value="">{{ _('All Statuses') }}</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>{{ _('Active') }}</option>
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>{{ _('Archived') }}</option>
<option value="inactive" {% if request.args.get('status') == 'inactive' %}selected{% endif %}>{{ _('Inactive') }}</option>
</select>
</div>
<div class="col-12 col-md-4 mobile-form-group">
<label for="client" class="form-label">{{ _('Client') }}</label>
<select class="form-select touch-target" id="client" name="client">
<option value="">{{ _('All Clients') }}</option>
{% for client in clients %}
<option value="{{ client }}" {% if request.args.get('client') == client %}selected{% endif %}>{{ client }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4 mobile-form-group">
<label for="search" class="form-label">{{ _('Search') }}</label>
<input type="text" class="form-control touch-target" id="search" name="search"
value="{{ request.args.get('search', '') }}" placeholder="{{ _('Project name or description') }}">
</div>
<div class="col-12 d-flex flex-column flex-md-row gap-2 mobile-stack">
<button type="submit" class="btn btn-primary mobile-btn flex-fill fw-semibold" style="height: 44px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-search me-1"></i>{{ _('Filter') }}
</button>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary mobile-btn flex-fill fw-semibold" style="height: 44px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-times me-1"></i>{{ _('Clear') }}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Projects List -->
<div class="row">
<div class="col-12">
<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>
{% if current_user.is_admin %}
<div class="btn-group" id="bulkActionsGroup">
<button type="button" id="bulkActionsBtn" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" disabled>
<i class="fas fa-tasks me-1"></i> {{ _('Bulk Actions') }} (<span id="selectedCount">0</span>)
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('active')"><i class="fas fa-check-circle me-2 text-success"></i>{{ _('Mark as Active') }}</a></li>
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('inactive')"><i class="fas fa-pause-circle me-2 text-warning"></i>{{ _('Mark as Inactive') }}</a></li>
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('archived')"><i class="fas fa-archive me-2 text-secondary"></i>{{ _('Archive') }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="showBulkDeleteConfirm()"><i class="fas fa-trash me-2"></i>{{ _('Delete') }}</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="card-body p-0">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover mb-0" id="projectsTable">
<thead class="table-light">
<tr>
{% if current_user.is_admin %}
<th class="border-0" style="width: 40px;">
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllProjects()">
</th>
{% endif %}
<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>
{% for project in projects %}
<tr>
{% if current_user.is_admin %}
<td>
<input type="checkbox" class="project-checkbox form-check-input" value="{{ project.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
<td data-label="Project">
<div>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-decoration-none">
<strong>{{ project.name }}</strong>
</a>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td data-label="Client">
<span class="project-badge">{{ project.client }}</span>
</td>
<td data-label="Status">
{% if project.status == 'active' %}
<span class="status-badge bg-success text-white">{{ _('Active') }}</span>
{% elif project.status == 'inactive' %}
<span class="status-badge bg-warning text-white">{{ _('Inactive') }}</span>
{% else %}
<span class="status-badge bg-secondary text-white">{{ _('Archived') }}</span>
{% endif %}
</td>
<td data-label="Hours" class="align-middle" style="min-width:180px;">
{% set total_h = (project.total_hours or 0) %}
{% set billable_h = (project.billable_hours or 0) %}
{% set pct = (billable_h / total_h * 100) if total_h > 0 else 0 %}
<div class="d-flex justify-content-between align-items-center mb-1">
<strong>{{ "%.1f"|format(total_h) }} h</strong>
<small class="text-success">{{ "%.1f"|format(billable_h) }} h {{ _('billable') }}</small>
</div>
<div class="progress bg-light rounded-pill" style="height:6px;">
<div class="progress-bar bg-success rounded-pill" role="progressbar" data-pct="{{ pct|round(0, 'floor') }}" aria-valuenow="{{ pct|round(0, 'floor') }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</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">{{ currency }}{{ "%.2f"|format(project.hourly_rate) }}/h</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="actions-cell" data-label="Actions">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view touch-target" title="{{ _('View project') }}">
<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-action btn-action--edit touch-target" title="{{ _('Edit project') }}">
<i class="fas fa-edit"></i>
</a>
{% if project.status == 'active' %}
<button type="button" class="btn btn-sm btn-action btn-action--warning touch-target" title="{{ _('Mark as inactive') }}"
onclick="confirmDeactivateProject('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-pause-circle"></i>
</button>
<button type="button" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Archive project') }}"
onclick="confirmArchiveProject('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-archive"></i>
</button>
{% elif project.status == 'inactive' %}
<button type="button" class="btn btn-sm btn-action btn-action--success touch-target" title="{{ _('Activate project') }}"
onclick="confirmActivateProject('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-check-circle"></i>
</button>
<button type="button" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Archive project') }}"
onclick="confirmArchiveProject('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-archive"></i>
</button>
{% else %}
<button type="button" class="btn btn-sm btn-action btn-action--success touch-target" title="{{ _('Unarchive project') }}"
onclick="confirmUnarchiveProject('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-box-open"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Delete project') }}"
onclick="confirmDeleteProject('{{ project.id }}', '{{ project.name }}', {{ 'true' if project.time_entries.count() > 0 else 'false' }})">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<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>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Bulk Action Forms (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('projects.bulk_delete_projects') }}" class="d-none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('projects.bulk_status_change') }}" class="d-none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
</form>
<!-- Bulk Status Change Confirmation Modal -->
<div class="modal fade" id="confirmBulkStatusChange" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-exchange-alt me-2 text-primary"></i>{{ _('Change Status') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="bulkStatusChangeMessage"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-primary" onclick="submitBulkStatusChange()">
<i class="fas fa-check me-2"></i>{{ _('Change Status') }}
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Delete Confirmation Modal -->
<div class="modal fade" id="confirmBulkDelete" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Selected Projects') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete the selected projects?') }}</p>
<p class="text-muted mb-0">{{ _('Projects with existing time entries will be skipped.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-danger" onclick="submitBulkDelete()">
<i class="fas fa-trash me-2"></i>{{ _('Delete Projects') }}
</button>
</div>
</div>
</div>
</div>
<style>
/* Filter toggle styles */
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: var(--transition-slow); }
/* Hover effect for sortable headers */
thead th[style*="cursor: pointer"]:hover { background-color: var(--surface-hover); color: var(--primary-color); }
[data-theme="dark"] thead th[style*="cursor: pointer"]:hover { background-color: var(--surface-hover); }
</style>
<script type="application/json" id="i18n-json-projects-list">
{
"search_projects": {{ _('Search projects:')|tojson }},
"length_menu": {{ _('Show _MENU_ projects per page')|tojson }},
"info": {{ _('Showing _START_ to _END_ of _TOTAL_ projects')|tojson }},
"status_active": {{ _('Active')|tojson }},
"status_inactive": {{ _('Inactive')|tojson }},
"status_archived": {{ _('Archived')|tojson }},
"deleting": {{ _('Deleting...')|tojson }},
"hide_filters": {{ _('Hide Filters')|tojson }},
"show_filters": {{ _('Show Filters')|tojson }},
"confirm_delete": {{ _('Are you sure you want to delete the project "{name}"?')|tojson }},
"confirm_delete_with_entries": {{ _('Cannot delete project "{name}" because it has time entries. Please delete the time entries first.')|tojson }},
"confirm_archive": {{ _('Are you sure you want to archive "{name}"?')|tojson }},
"confirm_unarchive": {{ _('Are you sure you want to unarchive "{name}"?')|tojson }},
"confirm_activate": {{ _('Are you sure you want to activate "{name}"?')|tojson }},
"confirm_deactivate": {{ _('Are you sure you want to mark "{name}" as inactive?')|tojson }}
}
</script>
<script>
var i18nProjects = (function(){ try{ var el = document.getElementById('i18n-json-projects-list'); return el ? JSON.parse(el.textContent) : {}; } catch(e){ return {}; } })();
</script>
<script>
// Bulk delete functions for projects
function toggleAllProjects() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.project-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
const count = checkboxes.length;
const btnGroup = document.getElementById('bulkActionsGroup');
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
// Update select all checkbox state
const allCheckboxes = document.querySelectorAll('.project-checkbox');
const selectAll = document.getElementById('selectAll');
if (selectAll && allCheckboxes.length > 0) {
selectAll.checked = count === allCheckboxes.length;
selectAll.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function showBulkDeleteConfirm() {
new bootstrap.Modal(document.getElementById('confirmBulkDelete')).show();
}
function submitBulkDelete() {
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
const form = document.getElementById('confirmBulkDelete-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="project_ids[]"]').forEach(input => input.remove());
// Add selected project IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'project_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Close modal and submit form
bootstrap.Modal.getInstance(document.getElementById('confirmBulkDelete')).hide();
form.submit();
}
function showBulkStatusChange(newStatus) {
const count = document.querySelectorAll('.project-checkbox:checked').length;
const statusLabels = {
'active': '{{ _("Active") }}',
'inactive': '{{ _("Inactive") }}',
'archived': '{{ _("Archived") }}'
};
const message = `{{ _("Are you sure you want to mark {count} project(s) as {status}?") }}`
.replace('{count}', count)
.replace('{status}', statusLabels[newStatus] || newStatus);
document.getElementById('bulkStatusChangeMessage').textContent = message;
document.getElementById('bulkNewStatus').value = newStatus;
new bootstrap.Modal(document.getElementById('confirmBulkStatusChange')).show();
return false;
}
function submitBulkStatusChange() {
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
const form = document.getElementById('bulkStatusChange-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="project_ids[]"]').forEach(input => input.remove());
// Add selected project IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'project_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Close modal and submit form
bootstrap.Modal.getInstance(document.getElementById('confirmBulkStatusChange')).hide();
form.submit();
}
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (filterBody.classList.contains('filter-collapsed')) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-up';
toggleButton.title = i18nProjects.hide_filters || 'Hide Filters';
localStorage.setItem('projectFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-down';
toggleButton.title = i18nProjects.show_filters || 'Show Filters';
localStorage.setItem('projectFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize filter visibility based on localStorage
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
// Check if user previously hid filters
const filtersVisible = localStorage.getItem('projectFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-down';
toggleButton.title = i18nProjects.show_filters || 'Show Filters';
}
// Add transition class after initial setup
setTimeout(() => {
filterBody.classList.add('filter-toggle-transition');
}, 100);
// Initialize vanilla JavaScript table functionality
initializeProjectsTable();
// Fill progress bars
document.querySelectorAll('#projectsTable .progress-bar').forEach(el => {
const pct = el.getAttribute('data-pct') || 0;
el.style.width = pct + '%';
});
});
// Vanilla JavaScript table functionality
function initializeProjectsTable() {
const table = document.getElementById('projectsTable');
const searchInput = document.getElementById('searchInput');
if (!table) return;
// Store original rows for filtering
const tbody = table.querySelector('tbody');
const originalRows = Array.from(tbody.querySelectorAll('tr'));
// Search functionality
if (searchInput) {
searchInput.addEventListener('keyup', function() {
const searchTerm = this.value.toLowerCase();
filterTable(searchTerm, 'all');
});
}
// Table sorting functionality
const headers = table.querySelectorAll('thead th');
headers.forEach((header, index) => {
// Skip the actions column (last column)
if (index === headers.length - 1) return;
header.style.cursor = 'pointer';
header.addEventListener('click', () => sortTable(index));
});
function filterTable(searchTerm, statusFilter) {
const rows = tbody.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
let textMatch = false;
let statusMatch = true;
// Check text match in project name and description
if (cells[0]) {
const projectText = cells[0].textContent.toLowerCase();
textMatch = searchTerm === '' || projectText.includes(searchTerm);
}
// Check status match
if (statusFilter !== 'all' && cells[2]) {
const statusText = cells[2].textContent.toLowerCase();
const activeLabel = (i18nProjects.status_active || 'Active').toLowerCase();
const archivedLabel = (i18nProjects.status_archived || 'Archived').toLowerCase();
if (statusFilter === 'active') {
statusMatch = statusText.includes(activeLabel);
} else if (statusFilter === 'archived') {
statusMatch = statusText.includes(archivedLabel);
}
}
row.style.display = (textMatch && statusMatch) ? '' : 'none';
});
}
function sortTable(columnIndex) {
const rows = Array.from(tbody.querySelectorAll('tr'));
const isAscending = !table.dataset.sortAsc || table.dataset.sortAsc === 'false';
rows.sort((a, b) => {
const aText = a.cells[columnIndex].textContent.trim();
const bText = b.cells[columnIndex].textContent.trim();
// Try to parse as numbers for numeric columns
const aNum = parseFloat(aText.replace(/[^\d.-]/g, ''));
const bNum = parseFloat(bText.replace(/[^\d.-]/g, ''));
if (!isNaN(aNum) && !isNaN(bNum)) {
return isAscending ? aNum - bNum : bNum - aNum;
} else {
return isAscending ? aText.localeCompare(bText) : bText.localeCompare(aText);
}
});
// Clear and re-append sorted rows
tbody.innerHTML = '';
rows.forEach(row => tbody.appendChild(row));
// Update sort indicator
table.dataset.sortAsc = isAscending.toString();
// Update header indicators
headers.forEach(h => h.classList.remove('sorted-asc', 'sorted-desc'));
headers[columnIndex].classList.add(isAscending ? 'sorted-asc' : 'sorted-desc');
}
// Store the filter function for external use
window.filterProjectsTable = filterTable;
}
function filterByStatus(status) {
const searchInput = document.getElementById('searchInput');
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (window.filterProjectsTable) {
window.filterProjectsTable(searchTerm, status);
}
document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('active'));
const active = document.querySelector(`[onclick="filterByStatus('${status}')"]`);
if (active) active.classList.add('active');
}
// Project action confirmation functions
function confirmDeleteProject(projectId, projectName, hasTimeEntries) {
if (hasTimeEntries) {
const msg = (i18nProjects.confirm_delete_with_entries || 'Cannot delete project "{name}" because it has time entries. Please delete the time entries first.').replace('{name}', projectName);
if (window.showAlert) {
window.showAlert(msg);
} else {
alert(msg);
}
return;
}
const msg = (i18nProjects.confirm_delete || 'Are you sure you want to delete the project "{name}"?').replace('{name}', projectName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
submitProjectAction(projectId, 'delete');
});
} else {
if (confirm(msg)) {
submitProjectAction(projectId, 'delete');
}
}
}
function confirmArchiveProject(projectId, projectName) {
const msg = (i18nProjects.confirm_archive || 'Are you sure you want to archive "{name}"?').replace('{name}', projectName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
submitProjectAction(projectId, 'archive');
});
} else {
if (confirm(msg)) {
submitProjectAction(projectId, 'archive');
}
}
}
function confirmUnarchiveProject(projectId, projectName) {
const msg = (i18nProjects.confirm_unarchive || 'Are you sure you want to unarchive "{name}"?').replace('{name}', projectName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
submitProjectAction(projectId, 'unarchive');
});
} else {
if (confirm(msg)) {
submitProjectAction(projectId, 'unarchive');
}
}
}
function confirmActivateProject(projectId, projectName) {
const msg = (i18nProjects.confirm_activate || 'Are you sure you want to activate "{name}"?').replace('{name}', projectName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
submitProjectAction(projectId, 'activate');
});
} else {
if (confirm(msg)) {
submitProjectAction(projectId, 'activate');
}
}
}
function confirmDeactivateProject(projectId, projectName) {
const msg = (i18nProjects.confirm_deactivate || 'Are you sure you want to mark "{name}" as inactive?').replace('{name}', projectName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
submitProjectAction(projectId, 'deactivate');
});
} else {
if (confirm(msg)) {
submitProjectAction(projectId, 'deactivate');
}
}
}
function submitProjectAction(projectId, action) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/projects/${projectId}/${action}`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
</script>
{% endblock %}

View File

@@ -1,706 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ project.name }} - {{ app_name }}{% endblock %}
{% block head_extra %}
<!-- Prevent page caching for kanban board -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
{% 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">
<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 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 me-1"></i> {{ _('Edit') }}
</a>
{% if project.status == 'active' %}
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary" data-confirm="{{ _('Archive this project?') }}">
<i class="fas fa-archive me-1"></i> {{ _('Archive') }}
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('projects.unarchive_project', project_id=project.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary" data-confirm="{{ _('Unarchive this project?') }}">
<i class="fas fa-box-open me-1"></i> {{ _('Unarchive') }}
</button>
</form>
{% endif %}
{% endif %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {{ _('Back') }}
</a>
<!-- Start Timer removed on project page -->
</div>
</div>
</div>
</div>
<!-- Project Details -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<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">
<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 %}
<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 %}
<div class="detail-row"><span class="detail-label">{{ _('Billing Ref') }}</span><span class="detail-value">{{ project.billing_ref }}</span></div>
{% endif %}
<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 class="section-title text-primary mb-2">{{ _('Description') }}</h6>
<div class="content-box prose prose-sm dark:prose-invert max-w-none">{{ project.description | markdown | safe }}</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<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">
<div class="col-6 mb-3">
<div class="h4 text-primary">{{ "%.1f"|format(project.total_hours) }}</div>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6 mb-3">
<div class="h4 text-success">{{ "%.1f"|format(project.total_billable_hours) }}</div>
<small class="text-muted">{{ _('Billable Hours') }}</small>
</div>
{% if project.estimated_hours %}
<div class="col-6 mb-3">
<div class="h4">{{ "%.1f"|format(project.estimated_hours) }}</div>
<small class="text-muted">{{ _('Estimated Hours') }}</small>
</div>
{% endif %}
<div class="col-6 mb-3">
<div class="h4 text-info">{{ currency }} {{ "%.2f"|format(project.total_costs) }}</div>
<small class="text-muted">{{ _('Total Costs') }}</small>
</div>
{% if project.budget_amount %}
<div class="col-6 mb-3">
<div class="h4">{{ currency }} {{ "%.2f"|format(project.budget_consumed_amount) }}</div>
<small class="text-muted">{{ _('Budget Used (Hours)') }}</small>
</div>
{% endif %}
{% if project.billable and project.hourly_rate %}
<div class="col-6">
<div class="h4 text-success">{{ currency }} {{ "%.2f"|format(project.total_project_value) }}</div>
<small class="text-muted">{{ _('Total Project Value') }}</small>
</div>
{% endif %}
</div>
{% if project.budget_amount %}
<div class="mt-3">
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100">
{% set pct = (project.budget_consumed_amount / project.budget_amount * 100) | round(0, 'floor') %}
<div class="progress-bar {% if pct >= project.budget_threshold_percent %}bg-danger{% else %}bg-primary{% endif %}" style="width: {{ pct }}%">{{ pct }}%</div>
</div>
<div class="d-flex justify-content-between small mt-1">
<span>{{ _('Budget') }}: {{ currency }} {{ "%.2f"|format(project.budget_amount) }}</span>
<span>{{ _('Threshold') }}: {{ project.budget_threshold_percent }}%</span>
</div>
</div>
{% endif %}
</div>
</div>
{% if project.billable and project.hourly_rate %}
<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() %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ user_total.username }}</span>
<span class="text-primary">{{ "%.1f"|format(user_total.total_hours) }}h</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Tasks -->
<div class="row mb-4">
<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-tasks"></i> {{ _('Tasks') }}
</h5>
<div>
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> {{ _('New Task') }}
</a>
<a href="{{ url_for('tasks.list_tasks', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-list"></i> {{ _('View All') }}
</a>
</div>
</div>
<div class="card-body">
{% set project_tasks = tasks %}
{% include 'tasks/_kanban.html' with context %}
{% if not tasks %}
<div class="mt-3">
{% from "_components.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> {{ _('Create First Task') }}
</a>
{% endset %}
{{ empty_state('fas fa-tasks', _('No Tasks Yet'), _('Break down this project into manageable tasks to track progress.'), actions) }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Project Costs -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-receipt me-2"></i>{{ _('Project Costs & Expenses') }}
</h6>
<a href="{{ url_for('projects.add_cost', project_id=project.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-plus me-1"></i>{{ _('Add Cost') }}
</a>
</div>
<div class="card-body">
{% if total_costs_count > 0 %}
<div class="row mb-3">
<div class="col-md-4">
<div class="text-center p-3 bg-light rounded">
<div class="h5 text-primary mb-0">{{ currency }} {{ "%.2f"|format(project.total_costs) }}</div>
<small class="text-muted">{{ _('Total Costs') }}</small>
</div>
</div>
<div class="col-md-4">
<div class="text-center p-3 bg-light rounded">
<div class="h5 text-success mb-0">{{ currency }} {{ "%.2f"|format(project.total_billable_costs) }}</div>
<small class="text-muted">{{ _('Billable Costs') }}</small>
</div>
</div>
<div class="col-md-4">
<div class="text-center p-3 bg-light rounded">
<div class="h5 text-info mb-0">{{ currency }} {{ "%.2f"|format(project.total_project_value) }}</div>
<small class="text-muted">{{ _('Total Project Value') }}</small>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{{ _('Date') }}</th>
<th>{{ _('Description') }}</th>
<th>{{ _('Category') }}</th>
<th class="text-end">{{ _('Amount') }}</th>
<th>{{ _('Billable') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for cost in recent_costs %}
<tr>
<td>{{ cost.cost_date.strftime('%Y-%m-%d') if cost.cost_date else 'N/A' }}</td>
<td>
{{ cost.description if cost.description else 'No description' }}
{% if cost.notes %}
<i class="fas fa-info-circle text-muted" title="{{ cost.notes }}"></i>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ _(cost.category.title() if cost.category else 'Other') }}</span>
</td>
<td class="text-end">
<strong>{{ cost.currency_code if cost.currency_code else 'EUR' }} {{ "%.2f"|format(cost.amount if cost.amount else 0) }}</strong>
</td>
<td>
{% if cost.billable %}
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('No') }}</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.edit_cost', project_id=project.id, cost_id=cost.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or cost.user_id == current_user.id %}
{% if not cost.is_invoiced %}
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete') }}"
onclick="showDeleteCostModal('{{ cost.id }}', '{{ cost.description }}', '{{ cost.amount }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_costs_count > 5 %}
<div class="text-center mt-2">
<a href="{{ url_for('projects.list_costs', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
{{ _('View All Costs') }} ({{ total_costs_count }})
</a>
</div>
{% endif %}
{% if not recent_costs %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>{{ _('Recent costs will appear here. Total costs for this project: ') }}{{ total_costs_count }}
</div>
{% endif %}
{% else %}
{% from "_components.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('projects.add_cost', project_id=project.id) }}" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> {{ _('Add First Cost') }}
</a>
{% endset %}
{{ empty_state('fas fa-receipt', _('No Costs Yet'), _('Track project expenses like travel, materials, and services.'), actions) }}
{% endif %}
</div>
</div>
</div>
</div>
<!-- Rates (Overrides) -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-money-bill-wave me-2"></i>{{ _('Rate Overrides') }}
</h6>
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}#rates" class="btn btn-sm btn-outline-primary">{{ _('Manage') }}</a>
</div>
<div class="card-body">
<div class="text-muted small">{{ _('Effective rates are applied in this order: user-specific override, project default override, project hourly rate, client default rate.') }}</div>
<div class="mt-2">
{% if project.hourly_rate %}
<div><strong>{{ _('Project rate') }}:</strong> {{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</div>
{% else %}
<div class="text-muted">{{ _('Project has no hourly rate; relying on overrides or client default.') }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Comments -->
<div class="row mb-4">
<div class="col-12">
<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-comments me-2"></i>{{ _('Project Comments') }}
</h6>
</div>
<div class="card-body">
{% include 'comments/_comments_section.html' with context %}
</div>
</div>
</div>
</div>
<!-- Time Entries -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-clock me-2"></i>{{ _('Time Entries') }}
</h6>
<div class="d-flex gap-2">
<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>
<button type="button" class="btn btn-sm btn-outline-secondary" id="showBurndownBtn">
<i class="fas fa-fire"></i> {{ _('Burn-down') }}
</button>
</div>
</div>
<div class="card-body">
<div id="burndownContainer" class="mb-3" style="display:none;">
<canvas id="burndownChart" height="100"></canvas>
</div>
{% if entries %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _('User') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Time') }}</th>
<th>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Tags') }}</th>
<th>{{ _('Billable') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.display_name }}</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.notes %}
<span title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.tag_list %}
{% for tag in entry.tag_list %}
<span class="badge bg-light text-dark">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('No') }}</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete') }}"
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav aria-label="Time entries pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.prev_num) }}">{{ _('Previous') }}</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=page_num) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.next_num) }}">{{ _('Next') }}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
{% from "_components.html" import empty_state %}
{{ empty_state('fas fa-clock', _('No Time Entries'), _('No time has been tracked for this project yet.')) }}
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
<!-- Delete Time Entry Modal -->
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Time Entry') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete the time entry for') }} <strong id="deleteEntryProjectName"></strong>?</p>
<p class="text-muted mb-0">{{ _('Duration:') }} <strong id="deleteEntryDuration"></strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteEntryForm" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Comment Modal -->
<div class="modal fade" id="deleteCommentModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Comment') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete this comment?') }}</p>
<div id="comment-preview" class="bg-light p-3 rounded mt-3" style="display: none;">
<div class="text-muted small mb-1">{{ _('Comment preview:') }}</div>
<div id="comment-preview-text"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteCommentForm" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>{{ _('Delete Comment') }}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Cost Modal -->
<div class="modal fade" id="deleteCostModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Cost') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete this cost?') }}</p>
<p class="mb-0"><strong>{{ _('Description:') }}</strong> <span id="deleteCostDescription"></span></p>
<p class="mb-0"><strong>{{ _('Amount:') }}</strong> <span id="deleteCostAmount"></span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteCostForm" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>{{ _('Delete Cost') }}
</button>
</form>
</div>
</div>
</div>
</div>
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script>
// Function to show delete time entry modal
function showDeleteEntryModal(entryId, projectName, duration) {
document.getElementById('deleteEntryProjectName').textContent = projectName;
document.getElementById('deleteEntryDuration').textContent = duration;
document.getElementById('deleteEntryForm').action = "{{ url_for('timer.delete_timer', timer_id=0) }}".replace('0', entryId);
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
}
// Function to show delete cost modal
function showDeleteCostModal(costId, description, amount) {
document.getElementById('deleteCostDescription').textContent = description;
document.getElementById('deleteCostAmount').textContent = '{{ currency }} ' + amount;
document.getElementById('deleteCostForm').action = "{{ url_for('projects.delete_cost', project_id=project.id, cost_id=0) }}".replace('0', costId);
new bootstrap.Modal(document.getElementById('deleteCostModal')).show();
}
// Add loading state to delete entry form
document.addEventListener('DOMContentLoaded', function() {
// Only add event listener if the form exists
const deleteEntryForm = document.getElementById('deleteEntryForm');
if (deleteEntryForm) {
deleteEntryForm.addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Deleting...') }}';
submitBtn.disabled = true;
});
}
// Add loading state to delete cost form
const deleteCostForm = document.getElementById('deleteCostForm');
if (deleteCostForm) {
deleteCostForm.addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Deleting...') }}';
submitBtn.disabled = true;
});
}
// Burndown chart toggle
const btn = document.getElementById('showBurndownBtn');
if (!btn) return;
const container = document.getElementById('burndownContainer');
let chart;
btn.addEventListener('click', async function(){
container.style.display = container.style.display === 'none' ? 'block' : 'none';
if (container.style.display === 'none') return;
try {
const res = await fetch(`/api/projects/{{ project.id }}/burndown`);
const data = await res.json();
const ctx = document.getElementById('burndownChart').getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [
{ label: '{{ _('Actual (cum hrs)') }}', data: data.actual_cumulative, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,.1)', tension:.2 },
{ label: '{{ _('Estimate') }}', data: data.estimated, borderColor: '#94a3b8', borderDash:[6,4], tension:0 }
]
},
options: { responsive: true, maintainAspectRatio: false }
});
} catch (e) { console.error(e); }
});
});
// Kanban column updates are handled by global socket in base.html
console.log('Project view page loaded - listening for kanban updates via global socket');
</script>
{% endblock %}

View File

@@ -1,274 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Reports') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('reports.export_csv') }}" class="btn-header btn-outline-primary">
<i class="fas fa-download"></i> {{ _('Export CSV') }}
</a>
{% endset %}
{{ page_header('fas fa-chart-line', _('Reports'), _('Analytics and summaries at a glance'), actions) }}
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.billable_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-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 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>
</div>
<!-- Report Options -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-file-alt me-2"></i>{{ _('Available Reports') }}
</h5>
</div>
<div class="card-body p-3">
<div class="reports-grid">
<!-- Project Report -->
<a href="{{ url_for('reports.project_report') }}" class="report-item">
<div class="report-item-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-project-diagram"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('Project Report') }}</h6>
<p class="report-item-description">{{ _('Time breakdown by project with detailed statistics') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
<!-- User Report -->
<a href="{{ url_for('reports.user_report') }}" class="report-item">
<div class="report-item-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-user"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('User Report') }}</h6>
<p class="report-item-description">{{ _('Track time by user with productivity metrics') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
<!-- Task Report -->
<a href="{{ url_for('reports.task_report') }}" class="report-item">
<div class="report-item-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-tasks"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('Task Report') }}</h6>
<p class="report-item-description">{{ _('Completed tasks with time spent analysis') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
<!-- Summary Report -->
<a href="{{ url_for('reports.summary_report') }}" class="report-item">
<div class="report-item-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-chart-line"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('Summary Report') }}</h6>
<p class="report-item-description">{{ _('Quick overview of key metrics and trends') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
<!-- Visual Analytics -->
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="report-item">
<div class="report-item-icon bg-purple bg-opacity-10 text-purple">
<i class="fas fa-chart-area"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('Visual Analytics') }}</h6>
<p class="report-item-description">{{ _('Interactive charts and data visualization') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
<!-- Data Export -->
<a href="{{ url_for('reports.export_csv') }}" class="report-item">
<div class="report-item-icon bg-secondary bg-opacity-10 text-secondary">
<i class="fas fa-download"></i>
</div>
<div class="report-item-content">
<h6 class="report-item-title">{{ _('Data Export') }}</h6>
<p class="report-item-description">{{ _('Export time entries to CSV format') }}</p>
</div>
<div class="report-item-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<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-history"></i> {{ _('Recent Activity') }}
</h5>
</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>{{ _('Task') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Billable') }}</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>
{% if entry.task %}
<a href="{{ url_for('tasks.view_task', task_id=entry.task.id) }}" class="text-decoration-none">
<i class="fas fa-tasks me-1 text-muted"></i>{{ entry.task.name }}
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.notes %}
<span title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('No') }}</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">{{ _('No time entries have been recorded recently.') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,523 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Project Report') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a></li>
<li class="breadcrumb-item active">{{ _('Project Report') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-chart-bar text-primary"></i> {{ _('Project Report') }}
</h1>
</div>
<div>
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-primary">
<i class="fas fa-download"></i> {{ _('Export CSV') }}
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm filters-card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> {{ _('Filters') }}
</h5>
</div>
<div class="card-body">
<!-- Date Range Presets -->
<div class="date-presets-container">
<span class="date-presets-label">
<i class="fas fa-calendar-day me-2"></i>{{ _('Quick Date Ranges') }}
</span>
<div id="datePresets"></div>
</div>
<form method="GET" class="row g-3" id="filtersForm">
<div class="col-md-3">
<label for="start_date" class="form-label">{{ _('Start Date') }}</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ request.args.get('start_date', '') }}">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">{{ _('End Date') }}</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ request.args.get('end_date', '') }}">
</div>
<div class="col-md-3">
<label for="project" class="form-label">{{ _('Project') }}</label>
<select class="form-select" id="project" name="project_id">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="user" class="form-label">{{ _('User') }}</label>
<select class="form-select" id="user" name="user_id">
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if request.args.get('user_id')|int == user.id %}selected{% endif %}>
{{ user.display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> {{ _('Apply Filters') }}
</button>
<a href="{{ url_for('reports.project_report') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> {{ _('Clear') }}
</a>
<div class="btn-group ms-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="saveFilterBtn"><i class="fas fa-bookmark me-1"></i>{{ _('Save Filter') }}</button>
<div class="btn-group" role="group">
<button id="filtersDropdown" type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-list me-1"></i>{{ _('Saved Filters') }}
</button>
<ul class="dropdown-menu" id="savedFiltersMenu"></ul>
</div>
</div>
<!-- Export Options -->
<div class="export-options float-end">
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-success btn-sm export-btn">
<i class="fas fa-file-csv"></i> {{ _('CSV') }}
</a>
<button type="button" class="btn btn-outline-primary btn-sm export-btn" onclick="window.print()">
<i class="fas fa-print"></i> {{ _('Print') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.billable_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-sack-dollar"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Amount') }}</div>
<div class="summary-value">{{ currency }} {{ "%.2f"|format(summary.total_billable_amount) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-project-diagram"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Projects') }}</div>
<div class="summary-value">{{ summary.projects_count }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Project Comparison Chart -->
{% if projects_data|length > 0 %}
<div class="row mb-4">
<div class="col-12">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-bar me-2"></i>{{ _('Project Hours Comparison') }}
</h5>
<div class="chart-controls">
<button type="button" class="chart-toggle-btn active" data-chart-type="bar">
<i class="fas fa-chart-bar"></i>
</button>
<button type="button" class="chart-toggle-btn" data-chart-type="line">
<i class="fas fa-chart-line"></i>
</button>
</div>
</div>
<canvas id="projectComparisonChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Project Breakdown -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list"></i> {{ _('Project Breakdown') }} ({{ projects_data|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" id="projectTableSearch" placeholder="{{ _('Search projects...') }}">
</div>
</div>
<div class="card-body p-0">
{% if projects_data|length > 0 and selected_project %}
<div class="p-3">
<canvas id="burndownAllChart" height="90"></canvas>
</div>
{% endif %}
{% if projects_data %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="projectsTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Client') }}</th>
<th data-sortable>{{ _('Total Hours') }}</th>
<th data-sortable>{{ _('Billable Hours') }}</th>
<th data-sortable>{{ _('Billable Amount') }}</th>
<th>{{ _('Users') }}</th>
<th class="text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects_data %}
<tr>
<td>
<div>
<strong>{{ project.name }}</strong>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td>
{% if project.client %}
{{ project.client }}
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
<strong>{{ "%.1f"|format(project.total_hours) }}h</strong>
</td>
<td>
{% if project.billable %}
<span class="text-success">{{ "%.1f"|format(project.billable_hours) }}h</span>
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
{% if project.billable and project.billable_amount > 0 %}
<span class="text-success">{{ currency }} {{ "%.2f"|format(project.billable_amount) }}</span>
{% else %}
<span class="text-muted">{{ _('-') }}</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-column">
{% for user_total in project.user_totals %}
<small>
{{ user_total.username }}: {{ "%.1f"|format(user_total.hours) }}h
</small>
{% endfor %}
</div>
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view" title="{{ _('View Project') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('reports.project_report') }}?project_id={{ project.id }}&{{ request.query_string.decode() }}"
class="btn btn-sm btn-action btn-action--more" title="{{ _('Filter by Project') }}">
<i class="fas fa-filter"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-chart-bar fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No Data Found') }}</h5>
<p class="text-muted">
{% if request.args.get('start_date') or request.args.get('end_date') or request.args.get('project_id') or request.args.get('user_id') %}
{{ _('Try adjusting your filters or') }}
<a href="{{ url_for('reports.project_report') }}">{{ _('view all projects') }}</a>.
{% else %}
{{ _('No time entries have been recorded yet.') }}
{% endif %}
</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Time Entries -->
{% if entries %}
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-clock"></i> {{ _('Time Entries') }} ({{ entries|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" data-table="entriesTable" placeholder="{{ _('Search entries...') }}">
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="entriesTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('User') }}</th>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Task') }}</th>
<th data-sortable>{{ _('Date') }}</th>
<th data-sortable>{{ _('Time') }}</th>
<th data-sortable>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Tags') }}</th>
<th data-sortable>{{ _('Billable') }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>
{% if entry.task %}
<a href="{{ url_for('tasks.view_task', task_id=entry.task.id) }}" class="text-decoration-none">
<i class="fas fa-tasks me-1 text-muted"></i>{{ entry.task.name }}
</a>
{% else %}
<span class="text-muted">{{ _('No task') }}</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.notes %}
<span title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.tag_list %}
{% for tag in entry.tag_list %}
<span class="badge bg-light text-dark">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='reports-enhanced.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', async function(){
try {
const projectId = Number(new URLSearchParams(window.location.search).get('project_id'));
if (!projectId) return; // Only draw when a project is selected
const res = await fetch(`/api/projects/${projectId}/burndown`);
const data = await res.json();
const ctx = document.getElementById('burndownAllChart').getContext('2d');
new Chart(ctx, { type: 'line', data: { labels: data.labels, datasets: [
{ label: '{{ _('Actual (cum hrs)') }}', data: data.actual_cumulative, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,.08)', tension:.2 },
{ label: '{{ _('Estimate') }}', data: data.estimated, borderColor: '#9ca3af', borderDash:[6,4], tension:0 }
] }, options: { responsive: true, maintainAspectRatio: false } });
} catch(e) { console.error(e); }
});
// Saved Filters UI
document.getElementById('saveFilterBtn')?.addEventListener('click', async function(){
const form = document.getElementById('filtersForm');
const params = new URLSearchParams(new FormData(form));
const payload = {};
if (params.get('project_id')) payload.project_id = Number(params.get('project_id'));
if (params.get('user_id')) payload.user_id = Number(params.get('user_id'));
if (params.get('start_date')) payload.start_date = params.get('start_date');
if (params.get('end_date')) payload.end_date = params.get('end_date');
const name = prompt('{{ _('Name this filter') }}');
if (!name) return;
try {
const res = await fetch('/api/saved-filters', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ name, scope:'reports', payload, is_shared:false }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
loadSavedFilters();
} catch(e) { /* ignore */ }
});
async function loadSavedFilters(){
try {
const res = await fetch('/api/saved-filters?scope=reports', { credentials:'same-origin' });
const json = await res.json();
const menu = document.getElementById('savedFiltersMenu');
if (!menu) return;
menu.innerHTML = '';
(json.filters || []).forEach(f => {
const li = document.createElement('li');
const a = document.createElement('a'); a.className='dropdown-item'; a.href='#'; a.textContent=f.name;
a.addEventListener('click', function(){ applySavedFilter(f); });
li.appendChild(a);
menu.appendChild(li);
});
} catch(e) {}
}
function applySavedFilter(f){
const form = document.getElementById('filtersForm');
if (!form || !f || !f.payload) return;
if (f.payload.start_date) form.querySelector('#start_date').value = f.payload.start_date;
if (f.payload.end_date) form.querySelector('#end_date').value = f.payload.end_date;
if (f.payload.project_id) form.querySelector('#project').value = String(f.payload.project_id);
if (f.payload.user_id) form.querySelector('#user').value = String(f.payload.user_id);
form.submit();
}
document.addEventListener('DOMContentLoaded', loadSavedFilters);
// Initialize Project Comparison Chart
document.addEventListener('DOMContentLoaded', function() {
{% if projects_data %}
const projectsData = [
{% for project in projects_data %}
{
name: "{{ project.name|safe }}",
total_hours: {{ project.total_hours }},
billable_hours: {{ project.billable_hours }},
billable_amount: {{ project.billable_amount }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (projectsData.length > 0) {
window.ReportsEnhanced.ReportCharts.createProjectComparisonChart('projectComparisonChart', projectsData);
}
{% endif %}
// Table search for projects
const projectSearch = document.getElementById('projectTableSearch');
if (projectSearch) {
projectSearch.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const table = document.getElementById('projectsTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}
{% endif %}
</div>
{% endblock %}

View File

@@ -1,319 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Summary Report') }} - {{ app_name %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a></li>
<li class="breadcrumb-item active">{{ _('Summary') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-chart-line text-primary"></i> {{ _('Summary Report') }}
</h1>
<p class="text-muted mb-0">{{ _('Quick overview of your time tracking metrics') }}</p>
</div>
<div>
<button type="button" class="btn btn-outline-primary no-print" onclick="window.print()">
<i class="fas fa-print"></i> {{ _('Print') }}
</button>
</div>
</div>
</div>
</div>
<!-- Key Metrics -->
<div class="row mb-4">
<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 class="progress-compact mt-2">
<div class="progress-bar bg-primary" style="width: {% if today_hours > 0 %}{{ (today_hours / 8 * 100)|round }}{% else %}0{% endif %}%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<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 class="progress-compact mt-2">
<div class="progress-bar bg-success" style="width: {% if week_hours > 0 %}{{ (week_hours / 40 * 100)|round }}{% else %}0{% endif %}%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<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 class="progress-compact mt-2">
<div class="progress-bar bg-info" style="width: {% if month_hours > 0 %}{{ (month_hours / 160 * 100)|round }}{% else %}0{% endif %}%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Time Trend Chart -->
{% if project_stats %}
<div class="row mb-4">
<div class="col-lg-8">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-area me-2"></i>{{ _('Project Hours (Last 30 Days)') }}
</h5>
</div>
<canvas id="projectHoursChart"></canvas>
</div>
</div>
<div class="col-lg-4">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-percentage me-2"></i>{{ _('Project Distribution') }}
</h5>
</div>
<canvas id="projectDistributionChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Top Projects -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-project-diagram"></i> {{ _('Top Projects') }} ({{ project_stats|length }})
</h5>
<div>
<a href="{{ url_for('reports.project_report') }}" class="btn btn-sm btn-outline-primary">
{{ _('View Full Report') }} <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
<div class="card-body p-0">
{% if project_stats %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table" id="projectsTable">
<thead class="table-light">
<tr>
<th>#</th>
<th>{{ _('Project') }}</th>
<th>{{ _('Client') }}</th>
<th>{{ _('Total Hours') }}</th>
<th>{{ _('% of Total') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% set total_hours = project_stats | sum(attribute='hours') %}
{% for item in project_stats %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=item.project.id) }}">
<strong>{{ item.project.name }}</strong>
</a>
</td>
<td>
{% if item.project.client %}
{{ item.project.client }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td><strong>{{ "%.1f"|format(item.hours) }}h</strong></td>
<td>
{% set percentage = (item.hours / total_hours * 100) if total_hours > 0 else 0 %}
<div class="d-flex align-items-center">
<div class="progress-compact flex-grow-1 me-2" style="width: 80px;">
<div class="progress-bar bg-primary" style="width: {{ percentage }}%"></div>
</div>
<span class="text-muted">{{ "%.1f"|format(percentage) }}%</span>
</div>
</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=item.project.id) }}"
class="btn btn-sm btn-action btn-action--view" title="{{ _('View Project') }}">
<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-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>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='reports-enhanced.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
{% if project_stats %}
const projectsData = [
{% for item in project_stats %}
{
name: "{{ item.project.name|safe }}",
hours: {{ item.hours }},
client: "{{ item.project.client|safe if item.project.client else 'N/A' }}"
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (projectsData.length > 0) {
// Project Hours Bar Chart
const ctx1 = document.getElementById('projectHoursChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: projectsData.map(p => p.name),
datasets: [{
label: '{{ _("Hours") }}',
data: projectsData.map(p => p.hours),
backgroundColor: '#3b82f680',
borderColor: '#3b82f6',
borderWidth: 2,
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y.toFixed(1) + 'h';
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: { callback: function(value) { return value + 'h'; } }
}
}
}
});
}
// Project Distribution Pie Chart
const ctx2 = document.getElementById('projectDistributionChart');
if (ctx2) {
new Chart(ctx2, {
type: 'doughnut',
data: {
labels: projectsData.map(p => p.name),
datasets: [{
data: projectsData.map(p => p.hours),
backgroundColor: [
'#3b82f6',
'#10b981',
'#f59e0b',
'#06b6d4',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1'
],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
boxWidth: 12,
font: { size: 11 },
padding: 8
}
},
tooltip: {
callbacks: {
label: function(context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((context.parsed / total) * 100).toFixed(1);
return context.label + ': ' + context.parsed.toFixed(1) + 'h (' + percentage + '%)';
}
}
}
}
}
});
}
}
{% endif %}
});
</script>
{% endblock %}

View File

@@ -1,322 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Finished Tasks Report') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a></li>
<li class="breadcrumb-item active">{{ _('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 filters-card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> {{ _('Filters') }}
</h5>
</div>
<div class="card-body">
<!-- Date Range Presets -->
<div class="date-presets-container">
<span class="date-presets-label">
<i class="fas fa-calendar-day me-2"></i>{{ _('Quick Date Ranges') }}
</span>
<div id="datePresets"></div>
</div>
<form method="GET" class="row g-3">
<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>
<!-- Export Options -->
<div class="export-options float-end">
<button type="button" class="btn btn-outline-success btn-sm export-btn" onclick="window.ReportsEnhanced.exportTableToCSV('tasksTable', 'tasks_report.csv')">
<i class="fas fa-file-csv"></i> {{ _('CSV') }}
</button>
<button type="button" class="btn btn-outline-primary btn-sm export-btn" onclick="window.print()">
<i class="fas fa-print"></i> {{ _('Print') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-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 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-chart-line"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Avg Hours/Task') }}</div>
<div class="summary-value">
{% if summary.tasks_count > 0 %}
{{ "%.1f"|format(summary.total_hours / summary.tasks_count) }}h
{% else %}
0h
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-check-circle"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Completion Rate') }}</div>
<div class="summary-value">100%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Task Completion Chart -->
{% if tasks|length > 0 %}
<div class="row mb-4">
<div class="col-12">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-bar me-2"></i>{{ _('Top Tasks by Hours') }}
</h5>
</div>
<canvas id="taskCompletionChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- 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 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list"></i> {{ _('Finished Tasks') }} ({{ tasks|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" id="taskTableSearch" placeholder="{{ _('Search tasks...') }}">
</div>
</div>
<div class="card-body p-0">
{% if tasks %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="tasksTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('Task') }}</th>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Assignee') }}</th>
<th data-sortable>{{ _('Completed') }}</th>
<th data-sortable>{{ _('Hours') }}</th>
<th data-sortable>{{ _('Entries') }}</th>
<th class="text-center">{{ _('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 class="text-center">
<a href="{{ url_for('tasks.view_task', task_id=row.task.id) }}" class="btn btn-sm btn-action btn-action--view" title="{{ _('View Task') }}">
<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 %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='reports-enhanced.js') }}"></script>
<script>
// Initialize Task Completion Chart
document.addEventListener('DOMContentLoaded', function() {
{% if tasks %}
const tasksData = [
{% for row in tasks[:10] %}
{
name: "{{ row.task.name|safe }}",
hours: {{ row.hours }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (tasksData.length > 0) {
window.ReportsEnhanced.ReportCharts.createTaskCompletionChart('taskCompletionChart', tasksData);
}
{% endif %}
// Table search for tasks
const taskSearch = document.getElementById('taskTableSearch');
if (taskSearch) {
taskSearch.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const table = document.getElementById('tasksTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}

View File

@@ -1,511 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('User Report') }} - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='reports.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a></li>
<li class="breadcrumb-item active">{{ _('User Report') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-chart-pie text-primary"></i> {{ _('User Report') }}
</h1>
</div>
<div>
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-primary">
<i class="fas fa-download"></i> {{ _('Export CSV') }}
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm filters-card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> {{ _('Filters') }}
</h5>
</div>
<div class="card-body">
<!-- Date Range Presets -->
<div class="date-presets-container">
<span class="date-presets-label">
<i class="fas fa-calendar-day me-2"></i>{{ _('Quick Date Ranges') }}
</span>
<div id="datePresets"></div>
</div>
<form method="GET" class="row g-3" id="filtersForm">
<div class="col-md-3">
<label for="start_date" class="form-label">{{ _('Start Date') }}</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ request.args.get('start_date', '') }}">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">{{ _('End Date') }}</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ request.args.get('end_date', '') }}">
</div>
<div class="col-md-3">
<label for="user" class="form-label">{{ _('User') }}</label>
<select class="form-select" id="user" name="user_id">
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if request.args.get('user_id')|int == user.id %}selected{% endif %}>
{{ user.display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="project" class="form-label">{{ _('Project') }}</label>
<select class="form-select" id="project" name="project_id">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> {{ _('Apply Filters') }}
</button>
<a href="{{ url_for('reports.user_report') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> {{ _('Clear') }}
</a>
<div class="btn-group ms-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="saveFilterBtn"><i class="fas fa-bookmark me-1"></i>{{ _('Save Filter') }}</button>
<div class="btn-group" role="group">
<button id="filtersDropdown" type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-list me-1"></i>{{ _('Saved Filters') }}
</button>
<ul class="dropdown-menu" id="savedFiltersMenu"></ul>
</div>
</div>
<!-- Export Options -->
<div class="export-options float-end">
<a href="{{ url_for('reports.export_csv') }}?{{ request.query_string.decode() }}" class="btn btn-outline-success btn-sm export-btn">
<i class="fas fa-file-csv"></i> {{ _('CSV') }}
</a>
<button type="button" class="btn btn-outline-primary btn-sm export-btn" onclick="window.print()">
<i class="fas fa-print"></i> {{ _('Print') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Billable Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(summary.billable_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-users"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Users') }}</div>
<div class="summary-value">{{ summary.users_count }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-project-diagram"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ _('Projects') }}</div>
<div class="summary-value">{{ summary.projects_count }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- User Distribution Chart -->
{% if user_totals|length > 0 %}
<div class="row mb-4">
<div class="col-lg-8">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-bar me-2"></i>{{ _('User Hours Distribution') }}
</h5>
</div>
<canvas id="userHoursChart"></canvas>
</div>
</div>
<div class="col-lg-4">
<div class="chart-container">
<div class="chart-header">
<h5 class="chart-title">
<i class="fas fa-chart-pie me-2"></i>{{ _('User Share') }}
</h5>
</div>
<canvas id="userDistributionChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- User Breakdown -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list"></i> {{ _('User Breakdown') }} ({{ user_totals|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" id="userTableSearch" placeholder="{{ _('Search users...') }}">
</div>
</div>
<div class="card-body p-0">
{% if user_totals %}
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="usersTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('User') }}</th>
<th data-sortable>{{ _('Total Hours') }}</th>
<th data-sortable>{{ _('Billable Hours') }}</th>
<th data-sortable>{{ _('Billable %') }}</th>
</tr>
</thead>
<tbody>
{% for username, totals in user_totals.items() %}
<tr>
<td><strong>{{ username }}</strong></td>
<td>{{ "%.1f"|format(totals.hours) }}h</td>
<td>
{% if totals.billable_hours > 0 %}
<span class="text-success">{{ "%.1f"|format(totals.billable_hours) }}h</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if totals.hours > 0 %}
{% set billable_percentage = (totals.billable_hours / totals.hours * 100) %}
<div class="d-flex align-items-center">
<div class="progress-compact flex-grow-1 me-2" style="width: 100px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ billable_percentage }}%"
aria-valuenow="{{ billable_percentage }}"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="text-muted">{{ "%.0f"|format(billable_percentage) }}%</span>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{{ _('No Data Found') }}</h5>
<p class="text-muted">{{ _('Try adjusting your filters.') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Time Entries -->
{% if entries %}
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-clock"></i> {{ _('Time Entries') }} ({{ entries|length }})
</h5>
<div class="table-search-container">
<input type="text" class="table-search" data-table="entriesTable" placeholder="{{ _('Search entries...') }}">
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 report-table sortable-table" id="entriesTable">
<thead class="table-light">
<tr>
<th data-sortable>{{ _('User') }}</th>
<th data-sortable>{{ _('Project') }}</th>
<th data-sortable>{{ _('Task') }}</th>
<th data-sortable>{{ _('Date') }}</th>
<th data-sortable>{{ _('Time') }}</th>
<th data-sortable>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Tags') }}</th>
<th data-sortable>{{ _('Billable') }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.user.display_name }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>
{% if entry.task %}
<a href="{{ url_for('tasks.view_task', task_id=entry.task.id) }}" class="text-decoration-none">
<i class="fas fa-tasks me-1 text-muted"></i>{{ entry.task.name }}
</a>
{% else %}
<span class="text-muted">{{ _('No task') }}</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.notes %}
<span title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.tag_list %}
{% for tag in entry.tag_list %}
<span class="badge bg-light text-dark">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('No') }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='reports-enhanced.js') }}"></script>
<script>
// Saved Filters UI
document.getElementById('saveFilterBtn')?.addEventListener('click', async function(){
const form = document.getElementById('filtersForm');
const params = new URLSearchParams(new FormData(form));
const payload = {};
if (params.get('project_id')) payload.project_id = Number(params.get('project_id'));
if (params.get('user_id')) payload.user_id = Number(params.get('user_id'));
if (params.get('start_date')) payload.start_date = params.get('start_date');
if (params.get('end_date')) payload.end_date = params.get('end_date');
const name = prompt('{{ _('Name this filter') }}');
if (!name) return;
try {
const res = await fetch('/api/saved-filters', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ name, scope:'reports', payload, is_shared:false }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
loadSavedFilters();
} catch(e) { /* ignore */ }
});
async function loadSavedFilters(){
try {
const res = await fetch('/api/saved-filters?scope=reports', { credentials:'same-origin' });
const json = await res.json();
const menu = document.getElementById('savedFiltersMenu');
if (!menu) return;
menu.innerHTML = '';
(json.filters || []).forEach(f => {
const li = document.createElement('li');
const a = document.createElement('a'); a.className='dropdown-item'; a.href='#'; a.textContent=f.name;
a.addEventListener('click', function(){ applySavedFilter(f); });
li.appendChild(a);
menu.appendChild(li);
});
} catch(e) {}
}
function applySavedFilter(f){
const form = document.getElementById('filtersForm');
if (!form || !f || !f.payload) return;
if (f.payload.start_date) form.querySelector('#start_date').value = f.payload.start_date;
if (f.payload.end_date) form.querySelector('#end_date').value = f.payload.end_date;
if (f.payload.project_id) form.querySelector('#project').value = String(f.payload.project_id);
if (f.payload.user_id) form.querySelector('#user').value = String(f.payload.user_id);
form.submit();
}
document.addEventListener('DOMContentLoaded', loadSavedFilters);
// Initialize User Charts
document.addEventListener('DOMContentLoaded', function() {
{% if user_totals %}
const usersData = [
{% for username, totals in user_totals.items() %}
{
name: "{{ username|safe }}",
hours: {{ totals.hours }},
billable_hours: {{ totals.billable_hours }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
if (usersData.length > 0) {
// Create bar chart for user hours
const ctx1 = document.getElementById('userHoursChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: usersData.map(u => u.name),
datasets: [
{
label: '{{ _("Total Hours") }}',
data: usersData.map(u => u.hours),
backgroundColor: '#3b82f640',
borderColor: '#3b82f6',
borderWidth: 2
},
{
label: '{{ _("Billable Hours") }}',
data: usersData.map(u => u.billable_hours),
backgroundColor: '#10b98140',
borderColor: '#10b981',
borderWidth: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' }
},
scales: {
y: {
beginAtZero: true,
ticks: { callback: function(value) { return value + 'h'; } }
}
}
}
});
}
// Create doughnut chart for user distribution
window.ReportsEnhanced.ReportCharts.createUserDistributionChart('userDistributionChart', usersData);
}
{% endif %}
// Table search for users
const userSearch = document.getElementById('userTableSearch');
if (userSearch) {
userSearch.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const table = document.getElementById('usersTable');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}

View File

@@ -1,363 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Log Time') }} - {{ app_name }}{% endblock %}
{% block content %}
{% block extra_css %}
<style>
/* Manual Entry alignment tweaks (light/dark consistent) */
.manual-entry .form-label { color: var(--text-primary); }
.manual-entry .card { border: 1px solid var(--border-color); }
.manual-entry .card-body .form-control,
.manual-entry .card-body .form-select { background: #ffffff; }
[data-theme="dark"] .manual-entry .card-body .form-control,
[data-theme="dark"] .manual-entry .card-body .form-select { background: #0f172a; color: var(--text-primary); border-color: var(--border-color); }
.manual-entry .form-control::placeholder { color: var(--text-muted); }
[data-theme="dark"] .manual-entry .form-control::placeholder { color: #64748b; }
.manual-entry .form-check-input { cursor: pointer; }
</style>
{% endblock %}
<div class="container-fluid manual-entry">
{% from "_components.html" import page_header %}
<div class="row g-3">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-arrow-left"></i> {{ _('Back') }}
</a>
{% endset %}
{{ page_header('fas fa-clock', _('Log Time'), _('Create a manual time entry'), actions) }}
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0">
<i class="fas fa-clock me-2 text-primary"></i>{{ _('Manual Entry') }}
</h6>
<div class="d-none d-md-flex gap-2">
<a href="{{ url_for('timer.manual_entry') }}" class="btn-header btn-outline-primary">
<i class="fas fa-rotate-right me-1"></i> {{ _('Reset') }}
</a>
</div>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('timer.manual_entry') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<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=""></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 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="mb-3">
<label for="task_id" class="form-label fw-semibold">
<i class="fas fa-tasks me-1"></i>{{ _('Task (optional)') }}
</label>
<select class="form-select" id="task_id" name="task_id" data-selected-task-id="{{ preselected_task_id or '' }}" disabled>
<option value=""></option>
</select>
<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="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">
<label for="start_date" class="form-label fw-semibold">{{ _('Date') }}</label>
<input type="date" class="form-control" name="start_date" id="start_date" required value="{{ request.form.get('start_date','') }}">
</div>
<div class="col-6">
<label for="start_time" class="form-label fw-semibold">{{ _('Time') }}</label>
<input type="time" class="form-control" name="start_time" id="start_time" required value="{{ request.form.get('start_time','') }}">
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<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">
<label for="end_date" class="form-label fw-semibold">{{ _('Date') }}</label>
<input type="date" class="form-control" name="end_date" id="end_date" required value="{{ request.form.get('end_date','') }}">
</div>
<div class="col-6">
<label for="end_time" class="form-label fw-semibold">{{ _('Time') }}</label>
<input type="time" class="form-control" name="end_time" id="end_time" required value="{{ request.form.get('end_time','') }}">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="my-3">
<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" style="height: 100px" placeholder="{{ _('What did you work on?') }}">{{ request.form.get('notes','') }}</textarea>
</div>
<div class="row g-3 align-items-center">
<div class="col-12 col-md-8">
<div class="mb-3 mb-md-0">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-control" id="tags" name="tags" placeholder="{{ _('tag1, tag2, tag3') }}" value="{{ request.form.get('tags','') }}">
<div class="form-text">{{ _('Separate tags with commas') }}</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="mb-3 mb-md-0">
<label class="form-label fw-semibold d-block" for="billable">
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
</label>
<div class="form-check form-switch 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 %}>
<span class="ms-2 text-muted small">{{ _('Include in invoices') }}</span>
</div>
</div>
</div>
</div>
<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>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{{ _('Save Entry') }}
</button>
<button type="reset" class="btn btn-outline-primary">
<i class="fas fa-broom me-2"></i>{{ _('Clear') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header">
<h6 class="m-0">
<i class="fas fa-lightbulb me-2 text-warning"></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 > label {
background-color: transparent;
color: var(--text-secondary);
}
.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);
}
/* Ensure task field label gets proper dark mode styling */
#task_id:focus ~ label,
#task_id:not([value=""]) ~ label {
color: var(--primary-color) !important;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
}
/* Fix form-text visibility in both light and dark modes */
.form-text {
color: var(--text-muted) !important;
font-size: 0.875rem;
margin-top: 0.5rem;
display: block;
position: relative;
z-index: 1;
}
[data-theme="dark"] .form-text {
color: var(--text-muted) !important;
}
.tip-icon { font-size: 18px; }
/* Match invoices page look-and-feel */
.card-header {
border-bottom: 1px solid var(--border-color);
}
.card.shadow-sm {
border: 1px solid var(--border-color);
}
.btn-outline-primary {
border-width: 1px;
}
.btn-outline-primary:hover {
color: #fff;
}
</style>
<script type="application/json" id="i18n-json-timer-manual">
{
"no_task": {{ _('No task')|tojson }},
"failed_load": {{ _('Failed to load tasks')|tojson }}
}
</script>
<script>
var i18nTimerManual = (function(){ try{ var el=document.getElementById('i18n-json-timer-manual'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
document.addEventListener('DOMContentLoaded', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
const now = new Date().toTimeString().slice(0, 5);
if (!document.getElementById('start_date').value) {
document.getElementById('start_date').value = today;
}
if (!document.getElementById('end_date').value) {
document.getElementById('end_date').value = today;
}
if (!document.getElementById('start_time').value) {
document.getElementById('start_time').value = now;
}
if (!document.getElementById('end_time').value) {
document.getElementById('end_time').value = now;
}
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Add mobile-specific classes
const form = document.querySelector('form');
form.classList.add('mobile-form');
// Improve touch targets
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
// Improve buttons
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
});
}
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
document.body.classList.add('mobile-view');
} else {
document.body.classList.remove('mobile-view');
}
});
// Dynamic task loading based on project selection
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const L = { noTask: i18nTimerManual.no_task || 'No task', failedLoad: i18nTimerManual.failed_load || 'Failed to load tasks' };
async function loadTasksForProject(projectId) {
if (!projectId) {
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
taskSelect.disabled = true;
return;
}
try {
const resp = await fetch(`/api/tasks?project_id=${projectId}`);
if (!resp.ok) throw new Error(L.failedLoad);
const data = await resp.json();
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
tasks.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t.id);
opt.textContent = t.name;
taskSelect.appendChild(opt);
});
// Preselect if provided
const preId = taskSelect.getAttribute('data-selected-task-id');
if (preId) {
const found = Array.from(taskSelect.options).some(o => o.value === preId);
if (found) taskSelect.value = preId;
// Clear after first use
taskSelect.setAttribute('data-selected-task-id', '');
}
taskSelect.disabled = false;
} catch (e) {
// On error, keep disabled
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
taskSelect.disabled = true;
}
}
// Initial load if project is already selected (from query/form)
if (projectSelect && projectSelect.value) {
loadTasksForProject(projectSelect.value);
}
// Reload tasks when project changes
projectSelect.addEventListener('change', function() {
// Clear any previous selection
taskSelect.value = '';
taskSelect.setAttribute('data-selected-task-id', '');
loadTasksForProject(this.value);
});
});
</script>
{% endblock %}

View File

@@ -1,876 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Timer') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h2 mb-1">
<i class="fas fa-clock text-primary me-2"></i>{{ _('Timer') }}
</h1>
<p class="text-muted mb-0">{{ _('Track your time with precision') }}</p>
</div>
<div>
<button type="button" id="openStartTimerBtn" class="btn-header btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
<button type="button" id="openFocusBtn" class="btn-header btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#focusModal">
<i class="fas fa-hourglass-start"></i>{{ _('Focus Mode') }}
</button>
<button type="button" id="resumeTimerBtn" class="btn-header btn-outline-primary d-none">
<i class="fas fa-redo"></i>{{ _('Resume Last') }}
</button>
</div>
</div>
</div>
</div>
<!-- Active Timer Section -->
<div class="row mb-4" id="activeTimerSection" style="display: none;">
<div class="col-12">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="fas fa-play-circle me-2"></i>{{ _('Active Timer') }}
</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-6">
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fas fa-play text-success fa-2x"></i>
</div>
</div>
<div>
<h5 id="activeProjectName" class="text-success mb-1"></h5>
<p id="activeTimerNotes" class="text-muted mb-0"></p>
</div>
</div>
<div class="d-flex align-items-center">
<i class="fas fa-clock text-muted me-2"></i>
<small class="text-muted">
{{ _('Started:') }} <span id="activeTimerStart" class="fw-semibold"></span>
</small>
</div>
</div>
<div class="col-md-3 text-center">
<div class="timer-display mb-2" id="activeTimerDisplay">00:00:00</div>
<small class="text-muted fw-semibold">{{ _('Duration') }}</small>
</div>
<div class="col-md-3 text-center text-md-end">
<button type="button" class="btn btn-danger px-4" id="stopTimerBtn">
<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- No Active Timer -->
<div class="row mb-4" id="noActiveTimerSection">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-clock fa-3x text-muted opacity-50"></i>
</div>
<h3 class="text-muted mb-3">{{ _('No Active Timer') }}</h3>
<p class="text-muted mb-4">{{ _('Start a timer to begin tracking your time effectively.') }}</p>
<button type="button" id="openStartTimerBtnEmpty" class="btn btn-primary px-4" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
</button>
<button type="button" id="resumeTimerBtnEmpty" class="btn btn-outline-primary px-4 ms-2">
<i class="fas fa-redo me-2"></i>{{ _('Resume Last') }}
</button>
</div>
</div>
</div>
</div>
<!-- Recent Entries -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2 text-primary"></i>{{ _('Recent Time Entries') }}
</h5>
</div>
<div class="card-body p-0">
<div id="recentEntriesList">
<!-- Entries will be loaded here -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Start Timer Modal -->
<div class="modal fade" id="startTimerModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-play me-2 text-success"></i>{{ _('Start Timer') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="startTimerForm">
<div class="modal-body">
<div class="mb-4">
<label for="projectSelect" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }} *
</label>
<select class="form-select form-select-lg" id="projectSelect" name="project_id" required>
<option value="">{{ _('Select a project...') }}</option>
</select>
</div>
<div class="mb-4">
<label for="timerNotes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
</label>
<textarea class="form-control" id="timerNotes" name="notes" rows="3"
placeholder="{{ _('What are you working on?') }}"></textarea>
</div>
<div class="mb-3">
<label for="timerTags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-control" id="timerTags" name="tags"
placeholder="{{ _('tag1, tag2, tag3') }}">
<div class="form-text">{{ _('Separate tags with commas') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="submit" class="btn btn-success">
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Focus Mode Modal -->
<div class="modal fade" id="focusModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-hourglass-half me-2 text-primary"></i>{{ _('Focus Mode') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">{{ _('Pomodoro (min)') }}</label>
<input type="number" class="form-control" id="pomodoroLen" value="25" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Short Break (min)') }}</label>
<input type="number" class="form-control" id="shortBreakLen" value="5" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break (min)') }}</label>
<input type="number" class="form-control" id="longBreakLen" value="15" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break Every') }}</label>
<input type="number" class="form-control" id="longBreakEvery" value="4" min="1">
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="linkActiveTimer" checked>
<label class="form-check-label" for="linkActiveTimer">{{ _('Link to active timer if running') }}</label>
</div>
<div class="mt-3 small text-muted" id="focusSummary"></div>
<div class="mt-3">
<div class="d-flex align-items-center">
<i class="fas fa-history text-primary me-2"></i>
<strong>{{ _('Recent Focus Sessions (7 days)') }}</strong>
</div>
<div id="focusHistory" class="small text-muted"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Close') }}</button>
<button class="btn btn-primary" id="startFocusSessionBtn"><i class="fas fa-play me-2"></i>{{ _('Start Focus') }}</button>
</div>
</div>
</div>
</div>
<!-- Edit Timer Modal -->
<div class="modal fade" id="editTimerModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-edit me-2 text-primary"></i>{{ _('Edit Time Entry') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editTimerForm" novalidate>
<div class="modal-body">
<input type="hidden" id="editEntryId" name="entry_id">
<div class="row">
<div class="col-md-6">
<div class="mb-4">
<label for="editProjectSelect" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }} *
</label>
<select class="form-select" id="editProjectSelect" name="project_id" required>
<option value="">{{ _('Select a project...') }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="fas fa-clock me-1"></i>{{ _('Duration') }}
</label>
<div class="form-control-plaintext" id="editDurationDisplay">--:--:--</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-4">
<label for="editStartTime" class="form-label fw-semibold">
<i class="fas fa-play me-1"></i>{{ _('Start Time') }} *
</label>
<input type="datetime-local" class="form-control" id="editStartTime" name="start_time" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-4">
<label for="editEndTime" class="form-label fw-semibold">
<i class="fas fa-stop me-1"></i>{{ _('End Time') }} *
</label>
<input type="datetime-local" class="form-control" id="editEndTime" name="end_time" required>
</div>
</div>
</div>
<div class="mb-4">
<label for="editNotes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
</label>
<textarea class="form-control" id="editNotes" name="notes" rows="3"></textarea>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-4">
<label for="editTags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-control" id="editTags" name="tags"
placeholder="{{ _('tag1, tag2, tag3') }}">
<div class="form-text">{{ _('Separate tags with commas') }}</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-4">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="editBillable" name="billable">
<label class="form-check-label fw-semibold" for="editBillable">
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" id="deleteTimerBtn">
<i class="fas fa-trash me-1"></i>{{ _('Delete') }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="submit" class="btn btn-primary" id="editSaveBtn">
<i class="fas fa-save me-2"></i>{{ _('Save Changes') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Entry Confirmation Modal -->
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Time Entry') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>{{ _('Are you sure you want to delete this time entry?') }}</p>
<p class="text-muted mb-0">{{ _('This will permanently remove the entry and cannot be recovered.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let activeTimer = null;
let timerInterval = null;
// Load projects for dropdowns
function loadProjects() {
const promise = fetch('/api/projects', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
const projectSelect = document.getElementById('projectSelect');
const editProjectSelect = document.getElementById('editProjectSelect');
if (!projectSelect || !editProjectSelect) return;
// Clear existing options
projectSelect.innerHTML = '<option value="">{{ _('Select a project...') }}</option>';
editProjectSelect.innerHTML = '<option value="">{{ _('Select a project...') }}</option>';
data.projects.forEach(project => {
if (project.status === 'active') {
const option = new Option(project.name, project.id);
projectSelect.add(option);
const editOption = new Option(project.name, project.id);
editProjectSelect.add(editOption);
}
});
return data.projects;
})
.catch(error => {
console.error('Error loading projects:', error);
showToast('{{ _('Error loading projects') }}', 'error');
});
window.projectsLoadedPromise = promise;
return promise;
}
// Check timer status
function checkTimerStatus() {
fetch('/api/timer/status', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
if (data.active && data.timer) {
activeTimer = data.timer;
showActiveTimer();
startTimerDisplay();
} else {
hideActiveTimer();
// show resume buttons
document.getElementById('resumeTimerBtn').classList.remove('d-none');
document.getElementById('resumeTimerBtnEmpty').classList.remove('d-none');
}
})
.catch(error => {
console.error('Error checking timer status:', error);
});
}
// Show active timer
function showActiveTimer() {
document.getElementById('noActiveTimerSection').style.display = 'none';
document.getElementById('activeTimerSection').style.display = 'block';
const openBtns = [document.getElementById('openStartTimerBtn'), document.getElementById('openStartTimerBtnEmpty')];
openBtns.forEach(btn => { if (btn) { btn.disabled = true; btn.classList.add('disabled'); btn.setAttribute('title', '{{ _('Stop the current timer first') }}'); }});
document.getElementById('activeProjectName').textContent = activeTimer.project_name;
document.getElementById('activeTimerNotes').textContent = activeTimer.notes || '{{ _('No notes') }}';
document.getElementById('activeTimerStart').textContent = new Date(activeTimer.start_time).toLocaleString();
}
// Hide active timer
function hideActiveTimer() {
document.getElementById('noActiveTimerSection').style.display = 'block';
document.getElementById('activeTimerSection').style.display = 'none';
activeTimer = null;
// Re-enable start buttons
const openBtns = [document.getElementById('openStartTimerBtn'), document.getElementById('openStartTimerBtnEmpty')];
openBtns.forEach(btn => { if (btn) { btn.disabled = false; btn.classList.remove('disabled'); btn.removeAttribute('title'); }});
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
// Start timer display
function startTimerDisplay() {
if (timerInterval) {
clearInterval(timerInterval);
}
timerInterval = setInterval(() => {
if (activeTimer) {
const now = new Date();
const start = new Date(activeTimer.start_time);
const duration = Math.floor((now - start) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
document.getElementById('activeTimerDisplay').textContent =
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
}, 1000);
}
// Load recent entries
function loadRecentEntries() {
fetch('/api/entries?limit=10', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
const container = document.getElementById('recentEntriesList');
if (data.entries.length === 0) {
container.innerHTML = `
<div class="text-center py-5">
<div class="mb-4">
<i class="fas fa-clock fa-4x text-muted opacity-50"></i>
</div>
<h5 class="text-muted mb-3">{{ _('No time entries yet') }}</h5>
<p class="text-muted mb-4">{{ _('Start tracking your time to see entries here') }}</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play me-2"></i>{{ _('Start Your First Timer') }}
</button>
</div>
`;
return;
}
container.innerHTML = data.entries.map(entry => `
<div class="d-flex justify-content-between align-items-center py-3 px-4 border-bottom">
<div class="d-flex align-items-center">
<div class="me-3">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
<i class="fas fa-project-diagram text-primary"></i>
</div>
</div>
<div>
<strong class="d-block">${entry.project_name}</strong>
${entry.notes ? `<small class="text-muted d-block">${entry.notes}</small>` : ''}
<small class="text-muted">
<i class="fas fa-calendar me-1"></i>
${new Date(entry.start_time).toLocaleDateString()}
<i class="fas fa-clock ms-2 me-1"></i>
${new Date(entry.start_time).toLocaleTimeString()} -
${entry.end_time ? new Date(entry.end_time).toLocaleTimeString() : '{{ _('Running') }}'}
</small>
</div>
</div>
<div class="text-end">
<div class="badge bg-primary fs-6 mb-2">${entry.duration_formatted}</div>
<br>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-action btn-action--edit" onclick="editEntry(${entry.id})" title="{{ _('Edit entry') }}">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-action btn-action--danger" onclick="deleteEntry(${entry.id})" title="{{ _('Delete entry') }}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
})
.catch(error => {
console.error('Error loading recent entries:', error);
});
}
// Start timer
document.getElementById('startTimerForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = {
project_id: formData.get('project_id'),
notes: formData.get('notes'),
tags: formData.get('tags')
};
// Show loading state
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Starting...') }}';
submitBtn.disabled = true;
fetch('/api/timer/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('{{ _('Timer started successfully') }}', 'success');
bootstrap.Modal.getInstance(document.getElementById('startTimerModal')).hide();
this.reset();
checkTimerStatus();
loadRecentEntries();
} else {
showToast(data.error || data.message || '{{ _('Error starting timer') }}', 'error');
}
})
.catch(error => {
console.error('Error starting timer:', error);
showToast('{{ _('Error starting timer') }}', 'error');
})
.finally(() => {
submitBtn.innerHTML = '<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}';
submitBtn.disabled = false;
});
});
// Stop timer
document.getElementById('stopTimerBtn').addEventListener('click', function() {
this.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Stopping...') }}';
this.disabled = true;
fetch('/api/timer/stop', {
method: 'POST',
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('{{ _('Timer stopped successfully') }}', 'success');
hideActiveTimer();
loadRecentEntries();
} else {
showToast(data.message || '{{ _('Error stopping timer') }}', 'error');
}
})
.catch(error => {
console.error('Error stopping timer:', error);
showToast('{{ _('Error stopping timer') }}', 'error');
})
.finally(() => {
this.innerHTML = '<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}';
this.disabled = false;
});
});
// Edit entry
function editEntry(entryId) {
const ensureProjects = window.projectsLoadedPromise || loadProjects();
Promise.resolve(ensureProjects).then(() => {
return fetch(`/api/entry/${entryId}`, { credentials: 'same-origin' })
.then(response => response.json());
})
.then(data => {
document.getElementById('editEntryId').value = entryId;
const editProjectSelect = document.getElementById('editProjectSelect');
// Ensure the project option exists; if not, append it
if (!Array.from(editProjectSelect.options).some(o => o.value == data.project_id)) {
const opt = new Option(data.project_name || `{{ _('Project') }} ${data.project_id}`, data.project_id);
editProjectSelect.add(opt);
}
editProjectSelect.value = data.project_id;
// Convert ISO to local datetime-local value
const startIso = data.start_time;
const endIso = data.end_time;
const toLocalInput = (iso) => {
if (!iso) return '';
const d = new Date(iso);
const pad = (n) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
document.getElementById('editStartTime').value = toLocalInput(startIso);
document.getElementById('editEndTime').value = toLocalInput(endIso);
document.getElementById('editNotes').value = data.notes || '';
document.getElementById('editTags').value = data.tags || '';
document.getElementById('editBillable').checked = data.billable;
// Calculate and display duration
updateEditDurationDisplay();
new bootstrap.Modal(document.getElementById('editTimerModal')).show();
})
.catch(error => {
console.error('Error loading entry:', error);
showToast('{{ _('Error loading entry') }}', 'error');
});
}
// Debug: signal that edit form handler is attached
console.debug('Edit form submit handler attached');
function performEditSave() {
const form = document.getElementById('editTimerForm');
if (!form) return;
const entryId = document.getElementById('editEntryId').value;
const formData = new FormData(form);
const data = {
project_id: formData.get('project_id'),
start_time: formData.get('start_time'),
end_time: formData.get('end_time'),
notes: formData.get('notes'),
tags: formData.get('tags'),
billable: formData.get('billable') === 'on'
};
console.debug('Submitting data', data);
// Validate
const startVal = data.start_time ? new Date(data.start_time) : null;
const endVal = data.end_time ? new Date(data.end_time) : null;
if (startVal && endVal && endVal <= startVal) {
showToast('{{ _('End time must be after start time') }}', 'error');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Saving...') }}';
submitBtn.disabled = true;
}
fetch(`/api/entry/${entryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(r => r.json())
.then(payload => {
console.debug('PUT response', payload);
if (payload.success) {
showToast('{{ _('Entry updated successfully') }}', 'success');
const modal = bootstrap.Modal.getInstance(document.getElementById('editTimerModal'));
if (modal) modal.hide();
loadRecentEntries();
} else {
showToast(payload.message || '{{ _('Error updating entry') }}', 'error');
}
})
.catch(err => {
console.error('Error updating entry:', err);
showToast('{{ _('Error updating entry') }}', 'error');
})
.finally(() => {
if (submitBtn) {
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>{{ _('Save Changes') }}';
submitBtn.disabled = false;
}
});
}
// Edit timer form
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
e.preventDefault();
console.debug('Edit form submit event fired');
performEditSave();
});
// Ensure click on Save triggers submit handler even if native validation blocks it
document.addEventListener('click', function(e) {
const btn = e.target.closest('#editSaveBtn');
if (!btn) return;
e.preventDefault();
performEditSave();
});
// Live update duration when times change in edit modal
function updateEditDurationDisplay() {
const start = document.getElementById('editStartTime').value;
const end = document.getElementById('editEndTime').value;
const display = document.getElementById('editDurationDisplay');
if (!start || !end) {
display.textContent = '--:--:--';
return;
}
const startDate = new Date(start);
const endDate = new Date(end);
const diff = Math.max(0, Math.floor((endDate - startDate) / 1000));
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
display.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
}
document.getElementById('editStartTime').addEventListener('change', updateEditDurationDisplay);
document.getElementById('editEndTime').addEventListener('change', updateEditDurationDisplay);
// Delete timer
document.getElementById('deleteTimerBtn').addEventListener('click', function() {
const entryId = document.getElementById('editEntryId').value;
// Store the entry ID and button reference for the modal
window.pendingDeleteEntryId = entryId;
window.pendingDeleteButton = this;
// Show the delete confirmation modal
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
});
// Delete entry directly
function deleteEntry(entryId) {
// Store the entry ID for the modal
window.pendingDeleteEntryId = entryId;
window.pendingDeleteButton = null;
// Show the delete confirmation modal
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
window.projectsLoadedPromise = loadProjects();
checkTimerStatus();
loadRecentEntries();
// Resume timer actions
const resumeButtons = [document.getElementById('resumeTimerBtn'), document.getElementById('resumeTimerBtnEmpty')];
resumeButtons.forEach(btn => {
if (!btn) return;
btn.addEventListener('click', async function(){
try {
btn.disabled = true; btn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Resuming...') }}';
const res = await fetch('/api/timer/resume', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({}) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
showToast('{{ _('Timer resumed') }}', 'success');
checkTimerStatus();
loadRecentEntries();
} catch(e) { showToast('{{ _('Nothing to resume') }}', 'warning'); }
finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-redo"></i>{{ _('Resume Last') }}'; }
});
});
// Refresh data periodically
setInterval(() => {
loadRecentEntries();
}, 30000); // Every 30 seconds
// Handle delete confirmation modal
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
const entryId = window.pendingDeleteEntryId;
const button = window.pendingDeleteButton;
if (!entryId) return;
// Show loading state if button exists
if (button) {
button.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Deleting...') }}';
button.disabled = true;
}
fetch(`/api/entry/${entryId}`, {
method: 'DELETE',
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('{{ _('Entry deleted successfully') }}', 'success');
// Hide modals
bootstrap.Modal.getInstance(document.getElementById('deleteEntryModal')).hide();
if (button) {
bootstrap.Modal.getInstance(document.getElementById('editTimerModal')).hide();
}
// Refresh data
loadRecentEntries();
if (button) {
checkTimerStatus();
}
} else {
showToast(data.message || '{{ _('Error deleting entry') }}', 'error');
}
})
.catch(error => {
console.error('Error deleting entry:', error);
showToast('{{ _('Error deleting entry') }}', 'error');
})
.finally(() => {
// Reset button state if it exists
if (button) {
button.innerHTML = '<i class="fas fa-trash me-1"></i>{{ _('Delete') }}';
button.disabled = false;
}
// Clear stored data
window.pendingDeleteEntryId = null;
window.pendingDeleteButton = null;
});
});
});
// Focus: session lifecycle
document.getElementById('startFocusSessionBtn').addEventListener('click', async function(){
const payload = {
pomodoro_length: Number(document.getElementById('pomodoroLen').value || 25),
short_break_length: Number(document.getElementById('shortBreakLen').value || 5),
long_break_length: Number(document.getElementById('longBreakLen').value || 15),
long_break_interval: Number(document.getElementById('longBreakEvery').value || 4),
link_active_timer: document.getElementById('linkActiveTimer').checked
};
const res = await fetch('/api/focus-sessions/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify(payload) });
const json = await res.json();
if (!json.success) { showToast(json.error || '{{ _('Failed to start focus session') }}', 'danger'); return; }
const session = json.session; window.__focusSessionId = session.id;
showToast('{{ _('Focus session started') }}', 'success');
bootstrap.Modal.getInstance(document.getElementById('focusModal')).hide();
// Simple countdown display under timer
const summary = document.getElementById('focusSummary');
if (summary) summary.textContent = '';
});
// When modal hidden, if session running we do nothing; finishing handled manually
});
// Optional: expose a finish function to be called by UI elsewhere
async function finishFocusSession(cyclesCompleted = 0, interruptions = 0, notes = ''){
if (!window.__focusSessionId) return;
try {
const res = await fetch('/api/focus-sessions/finish', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ session_id: window.__focusSessionId, cycles_completed: cyclesCompleted, interruptions: interruptions, notes }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
showToast('{{ _('Focus session saved') }}', 'success');
} catch(e) { showToast('{{ _('Failed to save focus session') }}', 'danger'); }
finally { window.__focusSessionId = null; }
}
// Load focus sessions summary when opening modal
document.getElementById('openFocusBtn').addEventListener('click', async function(){
try {
const res = await fetch('/api/focus-sessions/summary?days=7', { credentials: 'same-origin' });
const json = await res.json();
const el = document.getElementById('focusHistory');
if (el && json) {
el.textContent = `${json.total_sessions || 0} {{ _('sessions') }}, ${json.cycles_completed || 0} {{ _('cycles') }}, ${json.interruptions || 0} {{ _('interruptions') }}`;
}
} catch(e) {}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,552 @@
"""
Comprehensive tests for Favorite Projects functionality.
This module tests:
- UserFavoriteProject model creation and validation
- Relationships between User and Project models
- Favorite/unfavorite routes and API endpoints
- Filtering projects by favorites
- User permissions and access control
"""
import pytest
from datetime import datetime
from decimal import Decimal
from app import create_app, db
from app.models import User, Project, Client, UserFavoriteProject
@pytest.fixture
def app():
"""Create and configure a test application instance."""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'test-secret-key-do-not-use-in-production',
'SERVER_NAME': 'localhost:5000',
'APPLICATION_ROOT': '/',
'PREFERRED_URL_SCHEME': 'http',
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client_fixture(app):
"""Create a test Flask client."""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user."""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user.id
@pytest.fixture
def test_admin(app):
"""Create a test admin user."""
with app.app_context():
admin = User(username='admin', role='admin')
db.session.add(admin)
db.session.commit()
return admin.id
@pytest.fixture
def test_client(app):
"""Create a test client."""
with app.app_context():
client = Client(name='Test Client', description='A test client')
db.session.add(client)
db.session.commit()
return client.id
@pytest.fixture
def test_project(app, test_client):
"""Create a test project."""
with app.app_context():
project = Project(
name='Test Project',
client_id=test_client,
description='A test project',
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.add(project)
db.session.commit()
return project.id
@pytest.fixture
def test_project_2(app, test_client):
"""Create a second test project."""
with app.app_context():
project = Project(
name='Test Project 2',
client_id=test_client,
description='Another test project',
billable=True,
hourly_rate=Decimal('150.00')
)
db.session.add(project)
db.session.commit()
return project.id
# Model Tests
class TestUserFavoriteProjectModel:
"""Test UserFavoriteProject model creation, validation, and basic operations."""
def test_create_favorite(self, app, test_user, test_project):
"""Test creating a favorite project entry."""
with app.app_context():
favorite = UserFavoriteProject()
favorite.user_id = test_user
favorite.project_id = test_project
db.session.add(favorite)
db.session.commit()
assert favorite.id is not None
assert favorite.user_id == test_user
assert favorite.project_id == test_project
assert favorite.created_at is not None
assert isinstance(favorite.created_at, datetime)
def test_favorite_unique_constraint(self, app, test_user, test_project):
"""Test that a user cannot favorite the same project twice."""
with app.app_context():
# Create first favorite
favorite1 = UserFavoriteProject()
favorite1.user_id = test_user
favorite1.project_id = test_project
db.session.add(favorite1)
db.session.commit()
# Try to create duplicate
favorite2 = UserFavoriteProject()
favorite2.user_id = test_user
favorite2.project_id = test_project
db.session.add(favorite2)
# Should raise IntegrityError
with pytest.raises(Exception): # SQLAlchemy will raise IntegrityError
db.session.commit()
def test_favorite_to_dict(self, app, test_user, test_project):
"""Test favorite project to_dict method."""
with app.app_context():
favorite = UserFavoriteProject()
favorite.user_id = test_user
favorite.project_id = test_project
db.session.add(favorite)
db.session.commit()
data = favorite.to_dict()
assert 'id' in data
assert 'user_id' in data
assert 'project_id' in data
assert 'created_at' in data
assert data['user_id'] == test_user
assert data['project_id'] == test_project
class TestUserFavoriteProjectMethods:
"""Test User model methods for managing favorite projects."""
def test_add_favorite_project(self, app, test_user, test_project):
"""Test adding a project to user's favorites."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify it was added
assert user.is_project_favorite(project)
assert project in user.favorite_projects.all()
def test_add_favorite_project_idempotent(self, app, test_user, test_project):
"""Test that adding a favorite twice doesn't cause errors."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add twice
user.add_favorite_project(project)
user.add_favorite_project(project)
# Should still only have one favorite entry
favorites = UserFavoriteProject.query.filter_by(
user_id=test_user,
project_id=test_project
).all()
assert len(favorites) == 1
def test_remove_favorite_project(self, app, test_user, test_project):
"""Test removing a project from user's favorites."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add then remove
user.add_favorite_project(project)
assert user.is_project_favorite(project)
user.remove_favorite_project(project)
assert not user.is_project_favorite(project)
def test_is_project_favorite_with_id(self, app, test_user, test_project):
"""Test checking if project is favorite using project ID."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Not a favorite yet
assert not user.is_project_favorite(test_project)
# Add to favorites
user.add_favorite_project(project)
# Check with ID
assert user.is_project_favorite(test_project)
def test_get_favorite_projects(self, app, test_user, test_project, test_project_2):
"""Test getting user's favorite projects."""
with app.app_context():
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
project2 = db.session.get(Project, test_project_2)
# Add both to favorites
user.add_favorite_project(project1)
user.add_favorite_project(project2)
# Get favorites
favorites = user.get_favorite_projects()
assert len(favorites) == 2
assert project1 in favorites
assert project2 in favorites
def test_get_favorite_projects_filtered_by_status(self, app, test_user, test_project, test_project_2):
"""Test getting favorite projects filtered by status."""
with app.app_context():
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
project2 = db.session.get(Project, test_project_2)
# Set different statuses
project1.status = 'active'
project2.status = 'archived'
db.session.commit()
# Add both to favorites
user.add_favorite_project(project1)
user.add_favorite_project(project2)
# Get only active favorites
active_favorites = user.get_favorite_projects(status='active')
assert len(active_favorites) == 1
assert project1 in active_favorites
assert project2 not in active_favorites
class TestProjectFavoriteMethods:
"""Test Project model methods for favorite functionality."""
def test_is_favorited_by_user(self, app, test_user, test_project):
"""Test checking if project is favorited by a specific user."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Not favorited yet
assert not project.is_favorited_by(user)
# Add to favorites
user.add_favorite_project(project)
# Now should be favorited
assert project.is_favorited_by(user)
def test_is_favorited_by_user_id(self, app, test_user, test_project):
"""Test checking if project is favorited using user ID."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Check with user ID
assert project.is_favorited_by(test_user)
def test_project_to_dict_with_favorite_status(self, app, test_user, test_project):
"""Test project to_dict includes favorite status when user provided."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Without user, no is_favorite field
data = project.to_dict()
assert 'is_favorite' not in data
# With user, includes is_favorite
data_with_user = project.to_dict(user=user)
assert 'is_favorite' in data_with_user
assert data_with_user['is_favorite'] is False
# Add to favorites
user.add_favorite_project(project)
# Now should be True
data_favorited = project.to_dict(user=user)
assert data_favorited['is_favorite'] is True
# Route Tests
class TestFavoriteProjectRoutes:
"""Test favorite project routes and endpoints."""
def test_favorite_project_route(self, app, client_fixture, test_user, test_project):
"""Test favoriting a project via POST route."""
with app.app_context():
# Login as test user
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Favorite the project
response = client_fixture.post(
f'/projects/{test_project}/favorite',
headers={'X-Requested-With': 'XMLHttpRequest'}
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify in database
user = db.session.get(User, test_user)
assert user.is_project_favorite(test_project)
def test_unfavorite_project_route(self, app, client_fixture, test_user, test_project):
"""Test unfavoriting a project via POST route."""
with app.app_context():
# Setup: Add to favorites first
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
user.add_favorite_project(project)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Unfavorite the project
response = client_fixture.post(
f'/projects/{test_project}/unfavorite',
headers={'X-Requested-With': 'XMLHttpRequest'}
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify in database
user = db.session.get(User, test_user)
assert not user.is_project_favorite(test_project)
def test_favorite_nonexistent_project(self, app, client_fixture, test_user):
"""Test favoriting a non-existent project returns 404."""
with app.app_context():
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
response = client_fixture.post('/projects/99999/favorite')
assert response.status_code == 404
def test_favorite_project_requires_login(self, app, client_fixture, test_project):
"""Test that favoriting requires authentication."""
with app.app_context():
response = client_fixture.post(f'/projects/{test_project}/favorite')
# Should redirect to login
assert response.status_code in [302, 401]
class TestFavoriteProjectFiltering:
"""Test filtering projects by favorites."""
def test_list_projects_with_favorites_filter(self, app, client_fixture, test_user, test_project, test_project_2):
"""Test listing only favorite projects."""
with app.app_context():
# Setup: Favorite only one project
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
user.add_favorite_project(project1)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Request favorites only
response = client_fixture.get('/projects?favorites=true')
assert response.status_code == 200
# Check that the response contains the favorite project
assert b'Test Project' in response.data
def test_list_all_projects_without_filter(self, app, client_fixture, test_user, test_project, test_project_2):
"""Test listing all projects without favorites filter."""
with app.app_context():
# Setup: Favorite only one project
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
user.add_favorite_project(project1)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Request all projects
response = client_fixture.get('/projects')
assert response.status_code == 200
# Both projects should be in response
assert b'Test Project' in response.data
# Relationship Tests
class TestFavoriteProjectRelationships:
"""Test database relationships and cascade behavior."""
def test_delete_user_cascades_favorites(self, app, test_user, test_project):
"""Test that deleting a user removes their favorite entries."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify favorite exists
favorite_count = UserFavoriteProject.query.filter_by(user_id=test_user).count()
assert favorite_count == 1
# Delete user
db.session.delete(user)
db.session.commit()
# Favorite should be deleted
favorite_count = UserFavoriteProject.query.filter_by(user_id=test_user).count()
assert favorite_count == 0
def test_delete_project_cascades_favorites(self, app, test_user, test_project):
"""Test that deleting a project removes related favorite entries."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify favorite exists
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 1
# Delete project
db.session.delete(project)
db.session.commit()
# Favorite should be deleted
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 0
def test_multiple_users_favorite_same_project(self, app, test_user, test_admin, test_project):
"""Test that multiple users can favorite the same project."""
with app.app_context():
user = db.session.get(User, test_user)
admin = db.session.get(User, test_admin)
project = db.session.get(Project, test_project)
# Both favorite the same project
user.add_favorite_project(project)
admin.add_favorite_project(project)
# Verify both have it as favorite
assert user.is_project_favorite(project)
assert admin.is_project_favorite(project)
# Verify database has 2 entries
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 2
# Smoke Tests
class TestFavoriteProjectsSmoke:
"""Smoke tests to verify basic favorite projects functionality."""
def test_complete_favorite_workflow(self, app, test_user, test_project):
"""Test complete workflow: add favorite, check status, remove favorite."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Initially not favorited
assert not user.is_project_favorite(project)
# Add to favorites
user.add_favorite_project(project)
assert user.is_project_favorite(project)
# Get favorites list
favorites = user.get_favorite_projects()
assert len(favorites) == 1
assert project in favorites
# Remove from favorites
user.remove_favorite_project(project)
assert not user.is_project_favorite(project)
# Favorites list should be empty
favorites = user.get_favorite_projects()
assert len(favorites) == 0
def test_favorite_with_archived_projects(self, app, test_user, test_project):
"""Test that favoriting works with archived projects."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Favorite an active project
user.add_favorite_project(project)
# Archive the project
project.status = 'archived'
db.session.commit()
# Should still be favorited
assert user.is_project_favorite(project)
# But won't appear in active favorites
active_favorites = user.get_favorite_projects(status='active')
assert len(active_favorites) == 0
# Will appear in archived favorites
archived_favorites = user.get_favorite_projects(status='archived')
assert len(archived_favorites) == 1