mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-18 18:29:53 -06:00
Merge pull request #136 from DRYTRIX/Feat-Favourite-Projects
feat: add user favorite projects functionality with CSV export enhanc…
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
30
app/models/user_favorite_project.py
Normal file
30
app/models/user_favorite_project.py
Normal 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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
365
docs/FAVORITE_PROJECTS_FEATURE.md
Normal file
365
docs/FAVORITE_PROJECTS_FEATURE.md
Normal 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
|
||||
|
||||
@@ -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}
|
||||
|
||||
65
migrations/versions/023_add_user_favorite_projects.py
Normal file
65
migrations/versions/023_add_user_favorite_projects.py
Normal 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}")
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
552
tests/test_favorite_projects.py
Normal file
552
tests/test_favorite_projects.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user