feat: Enhance UI/UX with improved form validation and error handling

- Add comprehensive form validation system with real-time feedback
- Implement enhanced error handling with retry mechanisms and offline support
- Update route handlers for improved error responses
- Enhance list templates with better error handling and validation
- Update dashboard, timer, and report templates with enhanced UI
- Improve project service with better error handling
- Update config manager utilities
- Bump version to 4.2.0

Files updated:
- Routes: auth, clients, invoices, projects, quotes, tasks, timer, custom_reports
- Templates: base, dashboard, all list views, timer pages, reports
- Static: enhanced-ui.js, error-handling-enhanced.js, form-validation.js
- Services: project_service.py
- Utils: config_manager.py
- Version: setup.py
This commit is contained in:
Dries Peeters
2025-11-30 10:51:09 +01:00
parent 4cf4d0396a
commit ac465d9612
29 changed files with 2727 additions and 646 deletions

View File

@@ -14,6 +14,7 @@ from app import db, log_event, track_event
from app.models import User
from app.config import Config
from app.utils.db import safe_commit
from app.utils.config_manager import ConfigManager
from flask_babel import gettext as _
from app import oauth, limiter
from app.utils.posthog_segmentation import identify_user_with_segments, set_super_properties
@@ -80,9 +81,10 @@ def login():
if not username:
log_event("auth.login_failed", reason="empty_username", auth_method=auth_method)
flash(_("Username is required"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -98,15 +100,16 @@ def login():
current_app.logger.info("User lookup for '%s': %s", username, "found" if user else "not found")
if not user:
# Check if self-registration is allowed
if Config.ALLOW_SELF_REGISTER:
# Check if self-registration is allowed (use ConfigManager to respect database settings)
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
if allow_self_register:
# If password auth is required, validate password during self-registration
if requires_password:
if not password:
flash(_("Password is required to create an account."), "error")
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -114,7 +117,7 @@ def login():
flash(_("Password must be at least 8 characters long."), "error")
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -141,7 +144,7 @@ def login():
)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -158,7 +161,7 @@ def login():
flash(_("User not found. Please contact an administrator."), "error")
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -169,9 +172,10 @@ def login():
if not safe_commit("promote_admin_user", {"username": username}):
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
flash(_("Could not update your account role due to a database error."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -180,9 +184,10 @@ def login():
if not user.is_active:
log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method)
flash(_("Account is disabled. Please contact an administrator."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -197,9 +202,10 @@ def login():
"auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method
)
flash(_("Password is required"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -209,9 +215,10 @@ def login():
"auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method
)
flash(_("Invalid username or password"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -258,16 +265,18 @@ def login():
except Exception as e:
current_app.logger.exception("Login error: %s", e)
flash(_("Unexpected error during login. Please try again or check server logs."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=Config.ALLOW_SELF_REGISTER,
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
@@ -727,8 +736,9 @@ def oidc_callback():
user = User.query.filter_by(username=username).first()
if not user:
# Create if allowed
if not Config.ALLOW_SELF_REGISTER:
# Create if allowed (use ConfigManager to respect database settings)
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
if not allow_self_register:
flash(_("User account does not exist and self-registration is disabled."), "error")
return redirect(url_for("auth.login"))
role_name = "user"

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response, make_response
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
@@ -43,6 +43,18 @@ def list_clients():
clients = query.order_by(Client.name).all()
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the clients list HTML for AJAX requests
response = make_response(render_template(
"clients/_clients_list.html",
clients=clients,
status=status,
search=search,
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
return render_template("clients/list.html", clients=clients, status=status, search=search)

View File

@@ -97,14 +97,28 @@ def view_custom_report(view_id):
def preview_report():
"""Preview report data based on configuration."""
try:
data = request.json
# Validate JSON request
if not request.is_json:
return jsonify({"success": False, "message": "Request must be JSON"}), 400
data = request.get_json(silent=False)
if data is None:
return jsonify({"success": False, "message": "Invalid JSON in request body"}), 400
config = data.get("config", {})
# Validate that config is a dictionary
if not isinstance(config, dict):
return jsonify({"success": False, "message": "Config must be a dictionary"}), 400
# Generate report data
report_data = generate_report_data(config, current_user.id)
return jsonify({"success": True, "data": report_data})
except Exception as e:
# Log the error for debugging
from flask import current_app
current_app.logger.error(f"Error in preview_report: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": str(e)}), 500
@@ -141,14 +155,24 @@ def generate_report_data(config, user_id=None):
start_date = filters.get("start_date")
end_date = filters.get("end_date")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
else:
try:
if start_date and isinstance(start_date, str) and start_date.strip():
start_dt = datetime.strptime(start_date.strip(), "%Y-%m-%d")
else:
start_dt = datetime.utcnow() - timedelta(days=30)
except (ValueError, AttributeError) as e:
from flask import current_app
current_app.logger.warning(f"Invalid start_date format: {start_date}, using default")
start_dt = datetime.utcnow() - timedelta(days=30)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
else:
try:
if end_date and isinstance(end_date, str) and end_date.strip():
end_dt = datetime.strptime(end_date.strip(), "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
else:
end_dt = datetime.utcnow()
except (ValueError, AttributeError) as e:
from flask import current_app
current_app.logger.warning(f"Invalid end_date format: {end_date}, using default")
end_dt = datetime.utcnow()
# Generate data based on source
@@ -163,8 +187,15 @@ def generate_report_data(config, user_id=None):
if not user or not user.is_admin:
query = query.filter(TimeEntry.user_id == user_id)
if filters.get("project_id"):
query = query.filter(TimeEntry.project_id == filters["project_id"])
project_id = filters.get("project_id")
if project_id:
# Convert to int if it's a string
try:
project_id = int(project_id) if isinstance(project_id, str) else project_id
query = query.filter(TimeEntry.project_id == project_id)
except (ValueError, TypeError):
from flask import current_app
current_app.logger.warning(f"Invalid project_id: {project_id}, ignoring filter")
if filters.get("user_id"):
query = query.filter(TimeEntry.user_id == filters["user_id"])
@@ -178,7 +209,7 @@ def generate_report_data(config, user_id=None):
"project": e.project.name if e.project else "",
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"description": e.description or "",
"notes": e.notes or "",
}
for e in entries
],

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, make_response
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
@@ -58,6 +58,16 @@ def list_invoices():
is_admin=current_user.is_admin,
)
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the invoices list HTML for AJAX requests
response = make_response(render_template(
"invoices/_invoices_list.html",
invoices=result["invoices"],
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
return render_template("invoices/list.html", invoices=result["invoices"], summary=result["summary"])

View File

@@ -54,16 +54,23 @@ def list_projects():
from app.services import ProjectService
page = request.args.get("page", 1, type=int)
status = request.args.get("status", "active")
# Default to "all" if no status is provided (to show all projects)
# This allows search to work across all projects by default
status = request.args.get("status", "all")
# Handle "all" status - pass None to service to show all statuses
status_param = None if (status == "all" or not status) else status
client_name = request.args.get("client", "").strip()
search = request.args.get("search", "").strip()
favorites_only = request.args.get("favorites", "").lower() == "true"
# Debug logging
current_app.logger.debug(f"Projects list filters - search: '{search}', status: '{status}', client: '{client_name}', favorites: {favorites_only}")
project_service = ProjectService()
# Use service layer to get projects (prevents N+1 queries)
result = project_service.list_projects(
status=status,
status=status_param,
client_name=client_name if client_name else None,
search=search if search else None,
favorites_only=favorites_only,
@@ -79,11 +86,27 @@ def list_projects():
clients = Client.get_active_clients()
client_list = [c.name for c in clients]
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the projects list HTML for AJAX requests
from flask import make_response
response = make_response(render_template(
"projects/_projects_list.html",
projects=result["projects"],
pagination=result["pagination"],
favorite_project_ids=favorite_project_ids,
search=search,
status=status,
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
return render_template(
"projects/list.html",
projects=result["projects"],
pagination=result["pagination"],
status=status,
status=status or "all", # Ensure status is always set
search=search,
clients=client_list,
favorite_project_ids=favorite_project_ids,
favorites_only=favorites_only,
@@ -108,13 +131,14 @@ def export_projects():
db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == current_user.id),
)
# Filter by status
if status == "active":
query = query.filter(Project.status == "active")
elif status == "archived":
query = query.filter(Project.status == "archived")
elif status == "inactive":
query = query.filter(Project.status == "inactive")
# Filter by status (skip if "all" is selected)
if status and status != "all":
if status == "active":
query = query.filter(Project.status == "active")
elif status == "archived":
query = query.filter(Project.status == "archived")
elif status == "inactive":
query = query.filter(Project.status == "inactive")
if client_name:
query = query.join(Client).filter(Client.name == client_name)

View File

@@ -35,6 +35,19 @@ def list_quotes():
quotes = result["quotes"]
analytics = result.get("analytics")
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the quotes list HTML for AJAX requests
from flask import make_response
response = make_response(render_template(
"quotes/_quotes_list.html",
quotes=quotes,
status=status,
search=search,
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
return render_template(
"quotes/list.html",
quotes=quotes,

View File

@@ -51,6 +51,23 @@ def list_tasks():
db.session.expire_all()
kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else []
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the tasks list HTML for AJAX requests
response = make_response(render_template(
"tasks/_tasks_list.html",
tasks=result["tasks"],
pagination=result["pagination"],
status=status,
priority=priority,
project_id=project_id,
assigned_to=assigned_to,
search=search,
overdue=overdue,
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
# Prevent browser caching of kanban board
response = render_template(
"tasks/list.html",

View File

@@ -213,10 +213,24 @@ def start_timer():
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_started: %s", e)
# Invalidate dashboard cache so timer appears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
if task:
flash(f"Timer started for {project.name} - {task.name}", "success")
else:
elif project:
flash(f"Timer started for {project.name}", "success")
elif client:
flash(f"Timer started for {client.name}", "success")
else:
flash(_("Timer started"), "success")
return redirect(url_for("main.dashboard"))
@@ -284,6 +298,16 @@ def start_timer_from_template(template_id):
},
)
# Invalidate dashboard cache so timer appears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f'Timer started from template "{template.name}"', "success")
return redirect(url_for("main.dashboard"))
@@ -355,6 +379,16 @@ def start_timer_for_project(project_id):
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_started (GET): %s", e)
# Invalidate dashboard cache so timer appears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
if task_id:
task = Task.query.get(task_id)
task_name = task.name if task else "Unknown Task"
@@ -443,6 +477,16 @@ def stop_timer():
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
# Invalidate dashboard cache so timer disappears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success")
return redirect(url_for("main.dashboard"))
@@ -629,14 +673,37 @@ def delete_timer(timer_id):
flash(_("Cannot delete an active timer"), "error")
return redirect(url_for("main.dashboard"))
project_name = timer.project.name
# Get the name for the success message (project or client)
if timer.project:
target_name = timer.project.name
elif timer.client:
target_name = timer.client.name
else:
target_name = _("Unknown")
db.session.delete(timer)
if not safe_commit("delete_timer", {"timer_id": timer.id}):
flash(_("Could not delete timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
flash(f"Timer for {project_name} deleted successfully", "success")
return redirect(url_for("main.dashboard"))
# Invalidate dashboard cache so deleted entry disappears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s after deleting timer", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f"Timer for {target_name} deleted successfully", "success")
# Add cache-busting parameter to ensure fresh page load
import time
dashboard_url = url_for("main.dashboard")
separator = "&" if "?" in dashboard_url else "?"
redirect_url = f"{dashboard_url}{separator}_refresh={int(time.time())}"
return redirect(redirect_url)
@timer_bp.route("/timer/manual", methods=["GET", "POST"])
@@ -699,6 +766,13 @@ def manual_entry():
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
)
# Validate that either project or client is selected
@@ -712,6 +786,13 @@ def manual_entry():
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
)
# Parse datetime with timezone awareness
@@ -728,6 +809,13 @@ def manual_entry():
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
)
# Validate time range
@@ -741,6 +829,13 @@ def manual_entry():
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
)
# Use service to create entry (handles validation)
@@ -767,6 +862,13 @@ def manual_entry():
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
)
entry = result.get("entry")
@@ -1231,36 +1333,58 @@ def resume_timer(timer_id):
current_app.logger.info("Resume timer blocked: user already has an active timer")
return redirect(url_for("main.dashboard"))
# Check if project is still active
project = Project.query.get(timer.project_id)
if not project:
flash(_("Project no longer exists"), "error")
return redirect(url_for("main.dashboard"))
project = None
client = None
project_id = None
client_id = None
if project.status == "archived":
flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error")
return redirect(url_for("main.dashboard"))
elif project.status != "active":
flash(_("Cannot start timer for an inactive project"), "error")
return redirect(url_for("main.dashboard"))
# Check if timer is linked to a project or client
if timer.project_id:
# Timer is linked to a project
project = Project.query.get(timer.project_id)
if not project:
flash(_("Project no longer exists"), "error")
return redirect(url_for("main.dashboard"))
# Validate task if it exists
if timer.task_id:
task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first()
if not task:
# Task was deleted, continue without it
task_id = None
if project.status == "archived":
flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error")
return redirect(url_for("main.dashboard"))
elif project.status != "active":
flash(_("Cannot start timer for an inactive project"), "error")
return redirect(url_for("main.dashboard"))
project_id = timer.project_id
# Validate task if it exists
if timer.task_id:
task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first()
if not task:
# Task was deleted, continue without it
task_id = None
else:
task_id = timer.task_id
else:
task_id = timer.task_id
task_id = None
elif timer.client_id:
# Timer is linked to a client
client = Client.query.filter_by(id=timer.client_id, status="active").first()
if not client:
flash(_("Client no longer exists or is inactive"), "error")
return redirect(url_for("main.dashboard"))
client_id = timer.client_id
task_id = None # Tasks are not allowed for client-only timers
else:
task_id = None
flash(_("Timer is not linked to a project or client"), "error")
return redirect(url_for("main.dashboard"))
# Create new timer with copied properties
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=timer.project_id,
project_id=project_id,
client_id=client_id,
task_id=task_id,
start_time=local_now(),
notes=timer.notes,
@@ -1271,17 +1395,24 @@ def resume_timer(timer_id):
db.session.add(new_timer)
if not safe_commit(
"resume_timer", {"user_id": current_user.id, "original_timer_id": timer_id, "project_id": timer.project_id}
"resume_timer",
{
"user_id": current_user.id,
"original_timer_id": timer_id,
"project_id": project_id,
"client_id": client_id,
},
):
flash(_("Could not resume timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
current_app.logger.info(
"Resumed timer id=%s from original timer=%s for user=%s project_id=%s",
"Resumed timer id=%s from original timer=%s for user=%s project_id=%s client_id=%s",
new_timer.id,
timer_id,
current_user.username,
timer.project_id,
project_id,
client_id,
)
# Track timer resumed event
@@ -1290,7 +1421,8 @@ def resume_timer(timer_id):
user_id=current_user.id,
time_entry_id=new_timer.id,
original_timer_id=timer_id,
project_id=timer.project_id,
project_id=project_id,
client_id=client_id,
task_id=task_id,
description=timer.notes,
)
@@ -1300,7 +1432,8 @@ def resume_timer(timer_id):
{
"time_entry_id": new_timer.id,
"original_timer_id": timer_id,
"project_id": timer.project_id,
"project_id": project_id,
"client_id": client_id,
"task_id": task_id,
"has_notes": bool(timer.notes),
"has_tags": bool(timer.tags),
@@ -1308,17 +1441,30 @@ def resume_timer(timer_id):
)
# Log activity
project_name = project.name
task = Task.query.get(task_id) if task_id else None
task_name = task.name if task else None
if project:
project_name = project.name
task = Task.query.get(task_id) if task_id else None
task_name = task.name if task else None
entity_name = f"{project_name}" + (f" - {task_name}" if task_name else "")
description = f"Resumed timer for {project_name}" + (f" - {task_name}" if task_name else "")
elif client:
client_name = client.name
entity_name = client_name
description = f"Resumed timer for {client_name}"
task_name = None
else:
entity_name = _("Unknown")
description = _("Resumed timer")
task_name = None
Activity.log(
user_id=current_user.id,
action="started",
entity_type="time_entry",
entity_id=new_timer.id,
entity_name=f"{project_name}" + (f" - {task_name}" if task_name else ""),
description=f"Resumed timer for {project_name}" + (f" - {task_name}" if task_name else ""),
extra_data={"project_id": timer.project_id, "task_id": task_id, "resumed_from": timer_id},
entity_name=entity_name,
description=description,
extra_data={"project_id": project_id, "client_id": client_id, "task_id": task_id, "resumed_from": timer_id},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
@@ -1328,19 +1474,40 @@ def resume_timer(timer_id):
payload = {
"user_id": current_user.id,
"timer_id": new_timer.id,
"project_name": project_name,
"start_time": new_timer.start_time.isoformat(),
}
if project:
payload["project_name"] = project.name
if client:
payload["client_name"] = client.name
if task_id:
payload["task_id"] = task_id
payload["task_name"] = task_name
task = Task.query.get(task_id)
if task:
payload["task_id"] = task_id
payload["task_name"] = task.name
socketio.emit("timer_started", payload)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_resumed: %s", e)
if task_name:
flash(f"Timer resumed for {project_name} - {task_name}", "success")
# Invalidate dashboard cache so timer appears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
# Create success message
if project:
if task_name:
flash(f"Timer resumed for {project_name} - {task_name}", "success")
else:
flash(f"Timer resumed for {project_name}", "success")
elif client:
flash(f"Timer resumed for {client_name}", "success")
else:
flash(f"Timer resumed for {project_name}", "success")
flash(_("Timer resumed"), "success")
return redirect(url_for("main.dashboard"))

View File

@@ -237,18 +237,26 @@ class ProjectService:
db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == user_id),
)
# Filter by status
if status:
# Filter by status (skip if "all" is selected)
if status and status != "all":
query = query.filter(Project.status == status)
# Filter by client name
if client_name:
query = query.join(Client).filter(Client.name == client_name)
query = query.join(Client, Project.client_id == Client.id).filter(Client.name == client_name)
# Search filter
# Search filter - must be applied after any joins
if search:
like = f"%{search}%"
query = query.filter(db.or_(Project.name.ilike(like), Project.description.ilike(like)))
search = search.strip()
if search:
like = f"%{search}%"
# Use ilike for case-insensitive search on name and description
# Handle NULL descriptions properly
search_filter = db.or_(
Project.name.ilike(like),
db.and_(Project.description.isnot(None), Project.description.ilike(like))
)
query = query.filter(search_filter)
# Order and paginate
query = query.order_by(Project.name)

View File

@@ -356,6 +356,8 @@ class FilterManager {
constructor(formElement) {
this.form = formElement;
this.activeFilters = new Map();
this.submitTimeout = null;
this.inputTimeouts = new Map();
this.init();
}
@@ -365,8 +367,60 @@ class FilterManager {
this.chipsContainer.className = 'filter-chips-container';
this.form.parentNode.insertBefore(this.chipsContainer, this.form.nextSibling);
// Monitor form changes
this.form.addEventListener('change', () => this.updateFilters());
// Monitor form changes - auto-submit on dropdown changes
this.form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
this.updateFilters();
// Auto-submit on dropdown changes
this.submitForm();
});
});
// Monitor text input fields (search fields) - auto-submit with debouncing
this.inputTimeouts = new Map();
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
input.addEventListener('input', (e) => {
// Update filter chips immediately for visual feedback
this.updateFilters();
// Debounce the actual form submission
const timeoutKey = input.name || input.id;
if (this.inputTimeouts.has(timeoutKey)) {
clearTimeout(this.inputTimeouts.get(timeoutKey));
}
// Submit after user stops typing (500ms delay)
const timeout = setTimeout(() => {
this.submitForm();
this.inputTimeouts.delete(timeoutKey);
}, 500);
this.inputTimeouts.set(timeoutKey, timeout);
});
// Also submit on Enter key
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Clear any pending timeout
const timeoutKey = input.name || input.id;
if (this.inputTimeouts.has(timeoutKey)) {
clearTimeout(this.inputTimeouts.get(timeoutKey));
this.inputTimeouts.delete(timeoutKey);
}
// Submit immediately
this.submitForm();
}
});
});
// Listen to form submit - prevent default and use AJAX instead
this.form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
this.updateFilters();
this.submitForm();
});
// Add quick filters
this.addQuickFilters();
@@ -471,16 +525,234 @@ class FilterManager {
if (input) {
if (input.type === 'checkbox') {
input.checked = false;
} else if (input.tagName === 'SELECT') {
// For select elements, set to first option (usually "All" or empty)
if (input.options.length > 0) {
input.value = input.options[0].value;
} else {
input.value = '';
}
} else {
input.value = '';
}
this.form.dispatchEvent(new Event('submit', { bubbles: true }));
// Update filters and submit
this.updateFilters();
this.submitForm();
}
}
clearAll() {
// Reset all form fields
this.form.reset();
this.form.dispatchEvent(new Event('submit', { bubbles: true }));
// For select elements, ensure they're set to their default (first option)
this.form.querySelectorAll('select').forEach(select => {
if (select.options.length > 0) {
select.value = select.options[0].value;
}
});
// Explicitly set status to "all" to show all projects
const statusSelect = this.form.querySelector('[name="status"]');
if (statusSelect) {
statusSelect.value = 'all';
}
// Clear all text inputs
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
input.value = '';
});
// Update filters and submit
this.updateFilters();
this.submitForm();
}
submitForm() {
// Ensure the form can be submitted (remove any disabled state from submit button)
const submitButton = this.form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.style.display = '';
submitButton.style.visibility = '';
submitButton.style.opacity = '';
}
// For GET forms (filter forms), use AJAX to avoid page reload
if (this.form.method.toUpperCase() === 'GET') {
// Use a small delay to prevent rapid-fire submissions
if (this.submitTimeout) {
clearTimeout(this.submitTimeout);
}
this.submitTimeout = setTimeout(() => {
this.submitViaAjax();
}, 100);
} else {
// For POST forms, dispatch submit event (validation will handle it)
this.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
}
submitViaAjax() {
// Build query string from form data
const formData = new FormData(this.form);
const params = new URLSearchParams();
// Always include status - default to "all" if not set or empty
const statusSelect = this.form.querySelector('[name="status"]');
let statusValue = statusSelect ? statusSelect.value : 'all';
// If status is empty or null, default to "all" to show all projects
if (!statusValue || statusValue === '') {
statusValue = 'all';
}
params.append('status', statusValue);
// Process other form fields
for (const [key, value] of formData.entries()) {
// Skip status as we already handled it
if (key === 'status') {
continue;
}
// Include search if it has a value (trimmed)
else if (key === 'search') {
const trimmedValue = String(value || '').trim();
if (trimmedValue) {
params.append(key, trimmedValue);
}
}
// Include other fields if they have values
else if (value && String(value).trim() !== '') {
params.append(key, String(value).trim());
}
}
// Get the form action or current URL
const url = this.form.action || window.location.pathname;
const queryString = params.toString();
// Always include status parameter, even if it's the only one
const fullUrl = queryString ? `${url}?${queryString}` : `${url}?status=all`;
// Debug: log what we're sending
console.log('Filter URL:', fullUrl);
console.log('Search value:', params.get('search'));
console.log('Status value:', params.get('status'));
// Update URL without page reload
if (window.history && window.history.pushState) {
window.history.pushState({}, '', fullUrl);
}
// Show loading indicator
const container = document.getElementById('projectsListContainer') || document.getElementById('projectsContainer');
if (container) {
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
}
// Fetch filtered results via AJAX
fetch(fullUrl, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.status} ${response.statusText}`);
}
return response.text();
})
.then(html => {
// Debug: log response length
console.log('Response HTML length:', html.length);
console.log('Response preview:', html.substring(0, 200));
// Update the projects list container
const projectsContainer = document.getElementById('projectsListContainer');
const projectsWrapper = document.getElementById('projectsContainer');
if (!projectsContainer && projectsWrapper) {
// If we don't have the inner container, try to find or create it
let innerContainer = projectsWrapper.querySelector('#projectsListContainer');
if (!innerContainer) {
innerContainer = document.createElement('div');
innerContainer.id = 'projectsListContainer';
projectsWrapper.insertBefore(innerContainer, projectsWrapper.firstChild);
}
if (innerContainer) {
projectsContainer = innerContainer;
}
}
if (projectsContainer) {
const trimmedHtml = html.trim();
// The partial template returns: <div id="projectsListContainer">...</div>
// Extract the innerHTML from this div using a simple approach
// Create a temporary container and parse the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = trimmedHtml;
// Find the projectsListContainer in the parsed HTML
const responseContainer = tempDiv.querySelector('#projectsListContainer');
if (responseContainer) {
// Use the innerHTML directly
projectsContainer.innerHTML = responseContainer.innerHTML;
} else {
// If the response IS the container (no wrapper), extract content
// Try regex to get content between opening and closing div tags
const match = trimmedHtml.match(/<div[^>]*id=["']projectsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1] !== undefined) {
projectsContainer.innerHTML = match[1];
} else {
// If all else fails, try to find the first child element
const firstChild = tempDiv.firstElementChild;
if (firstChild && firstChild.id === 'projectsListContainer') {
projectsContainer.innerHTML = firstChild.innerHTML;
} else {
// Last resort: replace entire container
projectsContainer.outerHTML = trimmedHtml;
// Re-find the container after replacement
const newContainer = document.getElementById('projectsListContainer');
if (newContainer) projectsContainer = newContainer;
}
}
}
// Re-initialize any scripts that need to run after content update
if (window.setViewMode) {
const savedMode = localStorage.getItem('projectsViewMode') || 'list';
setViewMode(savedMode);
}
// Update filter chips after content update
this.updateFilters();
} else {
console.error('Could not find projectsListContainer or projectsContainer element');
}
})
.catch(error => {
console.error('Error fetching filtered projects:', error);
// Fallback to regular form submission on error
if (container) {
container.style.opacity = '';
container.style.pointerEvents = '';
}
// Optionally show an error message
if (window.toastManager) {
window.toastManager.show('Failed to filter projects. Please try again.', 'error');
}
})
.finally(() => {
// Remove loading indicator
if (container) {
container.style.opacity = '';
container.style.pointerEvents = '';
}
});
}
}
@@ -903,8 +1175,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// Initialize filter managers
// Initialize filter managers (skip forms that have custom handlers)
document.querySelectorAll('form[data-filter-form]').forEach(form => {
// Skip forms that have custom AJAX handlers
if (form.id === 'projectsFilterForm' ||
form.id === 'tasksFilterForm' ||
form.id === 'clientsFilterForm' ||
form.id === 'invoicesFilterForm' ||
form.id === 'quotesFilterForm') {
return;
}
new FilterManager(form);
});

View File

@@ -261,7 +261,9 @@ class EnhancedErrorHandler {
}
async handleFetchError(response, url, options) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
// Clone the response before reading it, so the caller can still read it
const clonedResponse = response.clone();
const errorData = await clonedResponse.json().catch(() => ({ error: 'Unknown error' }));
const userFriendlyMessage = this.getUserFriendlyMessage(response.status, errorData);
// Show error notification with retry option
@@ -274,7 +276,7 @@ class EnhancedErrorHandler {
this.queueForOffline(url, options, errorId);
}
// Return response (caller can handle it)
// Return original response so caller can handle it
return response;
}

View File

@@ -482,6 +482,13 @@ class FormValidator {
showSuccessMessage(field) {
if (!field.successContainer) return;
// Don't show success messages for checkboxes or radio buttons
// They don't need validation feedback like text inputs do
const input = field.element;
if (input.type === 'checkbox' || input.type === 'radio') {
return;
}
const successMessage = field.element.getAttribute('data-success-message') ||
'Looks good!';
field.successContainer.textContent = successMessage;

View File

@@ -192,7 +192,9 @@
errorStoppingTimer: '{{ _("Error stopping timer") }}',
noFormToSave: '{{ _("No form to save") }}',
noTimerFound: '{{ _("No timer found") }}',
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}'
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}',
selectProjectOrClient: '{{ _("Please select either a project or a client") }}',
endTimeAfterStartTime: '{{ _("End time must be after start time") }}'
}
};
</script>

View File

@@ -0,0 +1,106 @@
<div id="clientsListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<i class="fas fa-download mr-1"></i> Export
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllClients()">
</th>
{% endif %}
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Contact Person</th>
<th class="p-4" data-sortable>Email</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>Projects</th>
<th class="p-4">Portal</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin %}
<td class="p-4">
<input type="checkbox" class="client-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ client.id }}" onchange="updateClientsBulkState()">
</td>
{% endif %}
<td class="p-4 font-medium"><a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">{{ client.name }}</a></td>
<td class="p-4">{{ client.contact_person or '—' }}</td>
<td class="p-4">
{% if client.email %}
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if client.status == 'active' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
{% endif %}
</td>
<td class="p-4">
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
</td>
<td class="p-4">
{% if client.portal_enabled %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Portal: {{ client.portal_username }}">
<i class="fas fa-building mr-1"></i>Enabled
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not clients %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Client
</a>
{% endif %}
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if search or status != 'active' %}
{{ empty_state('fas fa-search', 'No Clients Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new client that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-users', 'No Clients Yet', 'Clients help you organize your projects and manage relationships. Create your first client to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>

View File

@@ -22,7 +22,7 @@
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" data-filter-form>
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" id="clientsFilterForm" 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">
@@ -35,118 +35,12 @@
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
<div class="col-span-full flex justify-end">
<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>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<i class="fas fa-download mr-1"></i> Export
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllClients()">
</th>
{% endif %}
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Contact Person</th>
<th class="p-4" data-sortable>Email</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>Projects</th>
<th class="p-4">Portal</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin %}
<td class="p-4">
<input type="checkbox" class="client-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ client.id }}" onchange="updateClientsBulkState()">
</td>
{% endif %}
<td class="p-4 font-medium"><a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">{{ client.name }}</a></td>
<td class="p-4">{{ client.contact_person or '—' }}</td>
<td class="p-4">
{% if client.email %}
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
{% else %}
{% endif %}
</td>
<td class="p-4">
{% if client.status == 'active' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
{% endif %}
</td>
<td class="p-4">
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
</td>
<td class="p-4">
{% if client.portal_enabled %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Portal: {{ client.portal_username }}">
<i class="fas fa-building mr-1"></i>Enabled
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not clients %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Client
</a>
{% endif %}
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if search or status != 'active' %}
{{ empty_state('fas fa-search', 'No Clients Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new client that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-users', 'No Clients Yet', 'Clients help you organize your projects and manage relationships. Create your first client to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="clientsContainer">
{% include 'clients/_clients_list.html' %}
</div>
{% if current_user.is_admin %}
@@ -214,6 +108,181 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
// Clients Filter Handler - AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('clientsFilterForm');
if (!form) return {};
const params = {};
// Get search input value directly
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
if (searchValue) {
params.search = searchValue;
}
}
// Get other form fields from FormData
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
if (key === 'search') continue;
const trimmed = String(value || '').trim();
if (trimmed && trimmed !== '' && trimmed !== 'all') {
params[key] = trimmed;
}
}
// Always include status, default to "all" if not set
if (!params.status) {
params.status = 'all';
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/clients?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('clientsListContainer');
if (!container) {
console.error('clientsListContainer not found');
return;
}
// Show loading state
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
// Update URL
if (window.history && window.history.pushState) {
window.history.pushState({}, '', url);
}
// Fetch filtered results
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html.trim();
const newContainer = tempDiv.querySelector('#clientsListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
const match = html.trim().match(/<div[^>]*id=["']clientsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
}
}
})
.catch(error => {
console.error('Filter error:', error);
container.style.opacity = '';
container.style.pointerEvents = '';
if (window.toastManager) {
window.toastManager.show('Failed to filter clients. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter clients. Please refresh the page.', 'error');
}
})
.finally(() => {
container.style.opacity = '';
container.style.pointerEvents = '';
});
}
function debouncedApplyFilters(delay = 100) {
if (filterTimeout) {
clearTimeout(filterTimeout);
}
filterTimeout = setTimeout(applyFilters, delay);
}
function debouncedSearch(delay = 500) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(applyFilters, delay);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('clientsFilterForm');
if (!form) {
console.error('Clients filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on search input (debounced)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
searchInput.addEventListener('input', () => {
debouncedSearch(500);
});
// Submit on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (filterTimeout) {
clearTimeout(filterTimeout);
}
applyFilters();
});
});
})();
{% if current_user.is_admin %}
function toggleAllClients(){
const selectAll = document.getElementById('selectAll');

View File

@@ -0,0 +1,157 @@
<div id="invoicesListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('invoices.export_invoices_excel') }}"
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
title="{{ _('Export to Excel') }}"
onclick="showExportLoading(this); return true;">
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'invoicesBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="invoicesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllInvoices()">
</th>
{% endif %}
<th class="p-4" data-sortable>Number</th>
<th class="p-4" data-sortable>Client</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>Payment</th>
<th class="p-4 table-number" data-sortable>Total</th>
<th class="p-4 table-number" data-sortable>Due Date</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin %}
<td class="p-4">
<input type="checkbox" class="invoice-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ invoice.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
<td class="p-4 font-medium">{{ invoice.invoice_number }}</td>
<td class="p-4">{{ invoice.client_name }}</td>
<td class="p-4">
{% set s = invoice.status %}
{% if s == 'draft' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
<i class="fas fa-file-alt mr-1"></i>Draft
</span>
{% elif s == 'sent' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
<i class="fas fa-paper-plane mr-1"></i>Sent
</span>
{% elif s == 'paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<i class="fas fa-check-circle mr-1"></i>Paid
</span>
{% elif s == 'overdue' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
<i class="fas fa-exclamation-triangle mr-1"></i>Overdue
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ s }}</span>
{% endif %}
</td>
<td class="p-4">
{% set ps = invoice.payment_status %}
{% if ps == 'unpaid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" title="{{ _('Unpaid') }}">
<i class="fas fa-times-circle mr-1"></i>Unpaid
</span>
{% elif ps == 'partially_paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" title="{{ _('Partially Paid') }}">
<i class="fas fa-hourglass-half mr-1"></i>Partial
</span>
{% elif ps == 'fully_paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300" title="{{ _('Fully Paid') }}">
<i class="fas fa-check-circle mr-1"></i>Paid
</span>
{% elif ps == 'overpaid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Overpaid">
<i class="fas fa-plus-circle mr-1"></i>Overpaid
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ ps }}</span>
{% endif %}
</td>
<td class="p-4 table-number">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-4 table-number">
{% if invoice.due_date %}
{% set is_overdue = invoice._is_overdue %}
<span class="chip whitespace-nowrap {% if is_overdue %}bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border-2 border-red-500{% else %}chip-neutral{% endif %}">
{% if is_overdue %}<i class="fas fa-exclamation-circle mr-1"></i>{% endif %}
{{ invoice.due_date.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4 relative overflow-visible">
<div class="relative inline-block">
<button type="button" onclick="toggleInvoiceActions(event, {{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
Actions <i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div id="invoiceActions{{ invoice.id }}" class="invoice-actions-dropdown hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg" style="z-index: 1200;">
<div class="py-1">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-eye mr-2"></i>View
</a>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-edit mr-2"></i>Edit
</a>
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-download mr-2"></i>Download PDF
</a>
{% if current_user.is_admin %}
<hr class="my-1 border-border-light dark:border-border-dark">
<button type="button" onclick="showDeleteModal({{ invoice.id }}, '{{ invoice.invoice_number }}')" class="w-full text-left block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-trash mr-2"></i>Delete
</button>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not invoices %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Invoice
</a>
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if request.args.get('search') or request.args.get('status') or request.args.get('client') %}
{{ empty_state('fas fa-search', 'No Invoices Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new invoice that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-file-invoice', 'No Invoices Yet', 'Create professional invoices to bill your clients for completed work. Create your first invoice to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>

View File

@@ -35,7 +35,7 @@
</button>
</div>
<div id="filterBody">
<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-4 gap-4" id="invoicesFilterForm" data-filter-form>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
<div class="flex flex-wrap gap-2">
@@ -71,169 +71,12 @@
<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="{{ request.args.get('search', '') }}" class="form-input" placeholder="{{ _('Invoice number or client') }}">
</div>
<div class="col-span-full flex justify-end">
<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>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('invoices.export_invoices_excel') }}"
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
title="{{ _('Export to Excel') }}"
onclick="showExportLoading(this); return true;">
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'invoicesBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="invoicesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllInvoices()">
</th>
{% endif %}
<th class="p-4" data-sortable>Number</th>
<th class="p-4" data-sortable>Client</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>Payment</th>
<th class="p-4 table-number" data-sortable>Total</th>
<th class="p-4 table-number" data-sortable>Due Date</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin %}
<td class="p-4">
<input type="checkbox" class="invoice-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ invoice.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
<td class="p-4 font-medium">{{ invoice.invoice_number }}</td>
<td class="p-4">{{ invoice.client_name }}</td>
<td class="p-4">
{% set s = invoice.status %}
{% if s == 'draft' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
<i class="fas fa-file-alt mr-1"></i>Draft
</span>
{% elif s == 'sent' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
<i class="fas fa-paper-plane mr-1"></i>Sent
</span>
{% elif s == 'paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<i class="fas fa-check-circle mr-1"></i>Paid
</span>
{% elif s == 'overdue' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
<i class="fas fa-exclamation-triangle mr-1"></i>Overdue
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ s }}</span>
{% endif %}
</td>
<td class="p-4">
{% set ps = invoice.payment_status %}
{% if ps == 'unpaid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" title="{{ _('Unpaid') }}">
<i class="fas fa-times-circle mr-1"></i>Unpaid
</span>
{% elif ps == 'partially_paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" title="{{ _('Partially Paid') }}">
<i class="fas fa-hourglass-half mr-1"></i>Partial
</span>
{% elif ps == 'fully_paid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300" title="{{ _('Fully Paid') }}">
<i class="fas fa-check-circle mr-1"></i>Paid
</span>
{% elif ps == 'overpaid' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Overpaid">
<i class="fas fa-plus-circle mr-1"></i>Overpaid
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ ps }}</span>
{% endif %}
</td>
<td class="p-4 table-number">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-4 table-number">
{% if invoice.due_date %}
{% set is_overdue = invoice._is_overdue %}
<span class="chip whitespace-nowrap {% if is_overdue %}bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border-2 border-red-500{% else %}chip-neutral{% endif %}">
{% if is_overdue %}<i class="fas fa-exclamation-circle mr-1"></i>{% endif %}
{{ invoice.due_date.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4 relative overflow-visible">
<div class="relative inline-block">
<button type="button" onclick="toggleInvoiceActions(event, {{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
Actions <i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div id="invoiceActions{{ invoice.id }}" class="invoice-actions-dropdown hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg" style="z-index: 1200;">
<div class="py-1">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-eye mr-2"></i>View
</a>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-edit mr-2"></i>Edit
</a>
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-download mr-2"></i>Download PDF
</a>
{% if current_user.is_admin %}
<hr class="my-1 border-border-light dark:border-border-dark">
<button type="button" onclick="showDeleteModal({{ invoice.id }}, '{{ invoice.invoice_number }}')" class="w-full text-left block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-trash mr-2"></i>Delete
</button>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not invoices %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Invoice
</a>
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if request.args.get('search') or request.args.get('status') or request.args.get('client') %}
{{ empty_state('fas fa-search', 'No Invoices Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new invoice that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-file-invoice', 'No Invoices Yet', 'Create professional invoices to bill your clients for completed work. Create your first invoice to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="invoicesContainer">
{% include 'invoices/_invoices_list.html' %}
</div>
<!-- Bulk Operations Forms (hidden) -->
@@ -356,10 +199,9 @@ function filterByStatus(status) {
btn.classList.add('active');
}
});
// Submit form
const form = document.querySelector('[data-filter-form]');
if (form) {
form.submit();
// Trigger AJAX filter
if (window.applyInvoiceFilters) {
window.applyInvoiceFilters();
}
}
@@ -489,6 +331,181 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
// Invoices Filter Handler - AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('invoicesFilterForm');
if (!form) return {};
const params = {};
// Get search input value directly
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
if (searchValue) {
params.search = searchValue;
}
}
// Get status from hidden input
const statusInput = form.querySelector('#statusFilter');
if (statusInput && statusInput.value) {
params.status = statusInput.value;
}
// Get payment status
const paymentStatusSelect = form.querySelector('[name="payment_status"]');
if (paymentStatusSelect && paymentStatusSelect.value) {
params.payment_status = paymentStatusSelect.value;
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/invoices?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('invoicesListContainer');
if (!container) {
console.error('invoicesListContainer not found');
return;
}
// Show loading state
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
// Update URL
if (window.history && window.history.pushState) {
window.history.pushState({}, '', url);
}
// Fetch filtered results
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html.trim();
const newContainer = tempDiv.querySelector('#invoicesListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
const match = html.trim().match(/<div[^>]*id=["']invoicesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
}
}
})
.catch(error => {
console.error('Filter error:', error);
container.style.opacity = '';
container.style.pointerEvents = '';
if (window.toastManager) {
window.toastManager.show('Failed to filter invoices. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter invoices. Please refresh the page.', 'error');
}
})
.finally(() => {
container.style.opacity = '';
container.style.pointerEvents = '';
});
}
// Export function for status filter buttons
window.applyInvoiceFilters = applyFilters;
function debouncedApplyFilters(delay = 100) {
if (filterTimeout) {
clearTimeout(filterTimeout);
}
filterTimeout = setTimeout(applyFilters, delay);
}
function debouncedSearch(delay = 500) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(applyFilters, delay);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('invoicesFilterForm');
if (!form) {
console.error('Invoices filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on search input (debounced)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
searchInput.addEventListener('input', () => {
debouncedSearch(500);
});
// Submit on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (filterTimeout) {
clearTimeout(filterTimeout);
}
applyFilters();
});
});
})();
{% if current_user.is_admin %}
// Bulk actions for invoices
function toggleAllInvoices() {

View File

@@ -153,60 +153,62 @@
<!-- Right Column: Real Insights -->
<div class="space-y-6">
<!-- Weekly Goal Widget -->
{% if current_week_goal %}
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-bullseye mr-2"></i>
{{ _('Weekly Goal') }}
</h2>
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
<i class="fas fa-external-link-alt"></i>
</a>
{% if settings and settings.ui_allow_weekly_goals and current_user.ui_show_weekly_goals %}
{% if current_week_goal %}
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-bullseye mr-2"></i>
{{ _('Weekly Goal') }}
</h2>
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2 opacity-90">
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
<span>{{ current_week_goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
<div class="bg-white rounded-full h-3 transition-all duration-300"
style="width: {{ current_week_goal.progress_percentage }}%"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
</div>
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
</div>
</div>
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
<div class="mt-3 text-sm opacity-90">
<i class="fas fa-info-circle mr-1"></i>
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
</div>
{% endif %}
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2 opacity-90">
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
<span>{{ current_week_goal.progress_percentage }}%</span>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-center">
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{{ _('No Weekly Goal') }}
</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
{{ _('Set a weekly time goal to track your progress') }}
</p>
<a href="{{ url_for('weekly_goals.create') }}"
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
</a>
</div>
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
<div class="bg-white rounded-full h-3 transition-all duration-300"
style="width: {{ current_week_goal.progress_percentage }}%"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
</div>
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
</div>
</div>
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
<div class="mt-3 text-sm opacity-90">
<i class="fas fa-info-circle mr-1"></i>
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
</div>
{% endif %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-center">
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{{ _('No Weekly Goal') }}
</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
{{ _('Set a weekly time goal to track your progress') }}
</p>
<a href="{{ url_for('weekly_goals.create') }}"
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
</a>
</div>
</div>
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-md dashboard-widget animated-card">
@@ -454,6 +456,44 @@
}
});
}
// Form validation: ensure either project or client is selected
const startTimerForm = modal ? modal.querySelector('form') : null;
if (startTimerForm) {
// Store original button state to restore if needed
const submitBtn = startTimerForm.querySelector('button[type="submit"]');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
startTimerForm.addEventListener('submit', function(e) {
// Validate project or client selection
const projectVal = projectSelect ? projectSelect.value : '';
const clientVal = clientSelect ? clientSelect.value : '';
if (!projectVal && !clientVal) {
e.preventDefault();
e.stopImmediatePropagation(); // Stop other handlers from running
// Ensure button state is preserved
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
// Show error message using toast notification
const errorMsg = '{{ _("Please select either a project or a client") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else {
alert(errorMsg);
}
// After showing error, ensure button is still in correct state
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
return false;
}
}, true); // Use capture phase to run before other handlers
}
}
// Template application function

View File

@@ -0,0 +1,295 @@
<div id="projectsListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<!-- View Toggle -->
<div class="flex items-center gap-1 bg-background-light dark:bg-background-dark rounded-lg p-1">
<button type="button" id="listViewBtn" onclick="setViewMode('list')" class="px-3 py-1.5 text-sm rounded transition-colors view-mode-btn active" data-view="list" title="{{ _('List View') }}">
<i class="fas fa-list"></i>
</button>
<button type="button" id="gridViewBtn" onclick="setViewMode('grid')" class="px-3 py-1.5 text-sm rounded transition-colors view-mode-btn" data-view="grid" title="{{ _('Grid View') }}">
<i class="fas fa-th"></i>
</button>
</div>
<a href="{{ url_for('projects.export_projects', status=request.args.get('status', 'active'), client=request.args.get('client', ''), search=request.args.get('search', ''), favorites=request.args.get('favorites', '')) }}"
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
title="{{ _('Export to CSV') }}"
onclick="showExportLoading(this); return true;">
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'projectsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="projectsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkArchiveDialog()"><i class="fas fa-archive mr-2 text-gray-600"></i>Archive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
<!-- List View -->
<div id="listView" class="view-container">
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-12">
<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>
<th class="p-4" data-sortable>Billable</th>
<th class="p-4" data-sortable>Rate</th>
<th class="p-4" data-sortable>Budget</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% 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 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"><a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">{{ project.name }}</a></td>
<td class="p-4">
{% if project.client_id %}
<a href="{{ url_for('clients.view_client', client_id=project.client_id) }}" class="text-primary hover:underline">{{ project.client }}</a>
{% else %}
{{ project.client }}
{% endif %}
</td>
<td class="p-4">
{% if project.status == 'active' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
{% elif project.status == 'inactive' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Inactive</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Archived</span>
{% endif %}
</td>
<td class="p-4">
{% if project.billable %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Billable</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">Non-billable</span>
{% endif %}
</td>
<td class="p-4">
{% if project.hourly_rate %}
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ '%.2f'|format(project.hourly_rate|float) }}/h</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
{% if project.budget_amount %}
{% set consumed = (project.budget_consumed_amount or 0.0) %}
{% set total = project.budget_amount|float %}
{% set pct = (consumed / total * 100) if total > 0 else 0 %}
{% if pct >= 90 %}
{% set badge_classes = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' %}
{% elif pct >= 70 %}
{% set badge_classes = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' %}
{% else %}
{% set badge_classes = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' %}
{% endif %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ badge_classes }}">{{ pct|round(0) }}%</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Grid View -->
<div id="gridView" class="view-container hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for project in projects %}
<div class="project-card bg-card-light dark:bg-card-dark rounded-lg shadow-md hover:shadow-lg transition-all duration-200 relative overflow-hidden group">
<!-- Quick Actions on Hover -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<div class="flex gap-1">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="p-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
title="{{ _('View Project') }}">
<i class="fas fa-eye text-xs"></i>
</a>
{% if current_user.is_admin or has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
title="{{ _('Edit Project') }}">
<i class="fas fa-edit text-xs"></i>
</a>
{% endif %}
<button type="button"
class="favorite-btn p-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors"
data-project-id="{{ project.id }}"
data-is-favorited="{{ 'true' if (favorite_project_ids and project.id in favorite_project_ids) else 'false' }}"
onclick="toggleFavorite({{ project.id }}, this)"
title="{{ 'Remove from favorites' if (favorite_project_ids and project.id in favorite_project_ids) else 'Add to favorites' }}">
<i class="{{ 'fas' if (favorite_project_ids and project.id in favorite_project_ids) else 'far' }} fa-star text-xs"></i>
</button>
</div>
</div>
<!-- Project Header -->
<div class="p-6 pb-4">
<div class="flex items-start justify-between mb-2">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark flex-1 pr-2">
{{ project.name }}
</h3>
<!-- Status Badge -->
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap
{% if project.status == 'active' %}
bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif project.status == 'inactive' %}
bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300
{% else %}
bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200
{% endif %}">
{{ project.status|title }}
</span>
</div>
{% if project.client %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
<i class="fas fa-building mr-1"></i>{{ project.client }}
</p>
{% endif %}
{% if project.description %}
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4 line-clamp-2 prose prose-sm dark:prose-invert max-w-none">
{{ project.description | markdown | safe }}
</div>
{% endif %}
</div>
<!-- Progress Indicators -->
<div class="px-6 pb-4 space-y-3">
{% if project.budget_amount %}
{% set consumed = (project.budget_consumed_amount or 0.0) %}
{% set total = project.budget_amount|float %}
{% set pct = (consumed / total * 100) if total > 0 else 0 %}
<div>
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark">Budget</span>
<span class="text-xs font-semibold
{% if pct >= 90 %}text-red-600
{% elif pct >= 70 %}text-amber-600
{% else %}text-green-600{% endif %}">
{{ pct|round(0) }}%
</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-300
{% if pct >= 90 %}bg-red-500
{% elif pct >= 70 %}bg-amber-500
{% else %}bg-green-500{% endif %}"
style="width: {{ [pct,100]|min }}%">
</div>
</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ "%.2f"|format(consumed) }} / {{ "%.2f"|format(total) }}
</div>
</div>
{% endif %}
<!-- Hours Progress -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark">Hours</span>
<span class="text-xs font-semibold text-text-light dark:text-text-dark">
{{ "%.1f"|format(project.total_hours) }}h
</span>
</div>
{% if project.estimated_hours %}
{% set hours_pct = (project.total_hours / project.estimated_hours * 100) if project.estimated_hours > 0 else 0 %}
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all duration-300"
style="width: {{ [hours_pct,100]|min }}%">
</div>
</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ "%.1f"|format(project.total_hours) }} / {{ "%.1f"|format(project.estimated_hours) }}h
</div>
{% endif %}
</div>
</div>
<!-- Project Footer -->
<div class="px-6 py-4 border-t border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-sm">
{% if project.billable %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<i class="fas fa-dollar-sign mr-1"></i>Billable
</span>
{% endif %}
{% if project.hourly_rate %}
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ '%.2f'|format(project.hourly_rate|float) }}/h
</span>
{% endif %}
</div>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="text-primary hover:text-primary/80 text-sm font-medium">
View <i class="fas fa-arrow-right ml-1"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% if not projects %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Project
</a>
{% endif %}
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if request.args.get('search') or request.args.get('client') or request.args.get('status') != 'all' %}
{{ empty_state('fas fa-search', 'No Projects Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new project that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-folder-open', 'No Projects Yet', 'Projects help you organize your work and track time efficiently. Create your first project to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>

View File

@@ -22,7 +22,7 @@
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4" data-filter-form>
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4" id="projectsFilterForm" 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">
@@ -30,7 +30,7 @@
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status" class="form-input">
<option value="all" {% if status == 'all' %}selected{% endif %}>All</option>
<option value="all" {% if not status or status == 'all' %}selected{% endif %}>All</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="archived" {% if status == 'archived' %}selected{% endif %}>Archived</option>
@@ -52,14 +52,12 @@
<option value="true" {% if favorites_only %}selected{% endif %}>⭐ Favorites Only</option>
</select>
</div>
<div class="col-span-full flex justify-end">
<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>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="projectsContainer">
<div id="projectsListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
@@ -354,6 +352,7 @@
{{ empty_state('fas fa-folder-open', 'No Projects Yet', 'Projects help you organize your work and track time efficiently. Create your first project to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}
@@ -735,5 +734,313 @@ function toggleFavorite(projectId, button) {
button.disabled = false;
});
}
// Projects Filter Handler - Simple AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('projectsFilterForm');
if (!form) return {};
const params = {};
// Get search input value directly (more reliable than FormData for text inputs)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
if (searchValue) {
params.search = searchValue;
}
}
// Get other form fields from FormData
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
// Skip search as we already handled it above
if (key === 'search') {
continue;
}
const trimmed = String(value || '').trim();
if (trimmed && trimmed !== '') {
params[key] = trimmed;
}
}
// Always include status, default to "all" if not set
if (!params.status) {
params.status = 'all';
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/projects?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('projectsListContainer');
if (!container) {
console.error('projectsListContainer not found');
return;
}
// Show loading state
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
// Update URL
if (window.history && window.history.pushState) {
window.history.pushState({}, '', url);
}
// Fetch filtered results
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
// The response should be the projectsListContainer div
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html.trim();
const newContainer = tempDiv.querySelector('#projectsListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
// Fallback: try to extract content using regex
const match = html.trim().match(/<div[^>]*id=["']projectsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
// Last resort: use the HTML as-is
container.innerHTML = html;
}
}
// Re-initialize view mode
if (window.setViewMode) {
const savedMode = localStorage.getItem('projectsViewMode') || 'list';
setViewMode(savedMode);
}
// Update filter chips after successful filter
updateFilterChips();
})
.catch(error => {
console.error('Filter error:', error);
// Restore container state
container.style.opacity = '';
container.style.pointerEvents = '';
// Show error message
if (window.toastManager) {
window.toastManager.show('Failed to filter projects. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter projects. Please refresh the page.', 'error');
}
})
.finally(() => {
container.style.opacity = '';
container.style.pointerEvents = '';
});
}
function debouncedApplyFilters(delay = 100) {
if (filterTimeout) {
clearTimeout(filterTimeout);
}
filterTimeout = setTimeout(applyFilters, delay);
}
function debouncedSearch(delay = 500) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(applyFilters, delay);
}
function updateFilterChips() {
const form = document.getElementById('projectsFilterForm');
if (!form) return;
const params = getFilterParams();
const chipsContainer = document.getElementById('filterChipsContainer');
// Remove old chips container if it exists
if (chipsContainer) {
chipsContainer.remove();
}
// Get active filters (exclude empty values and "all" status)
const activeFilters = {};
Object.entries(params).forEach(([key, value]) => {
if (value && value !== 'all' && value !== '' && value !== 'false') {
activeFilters[key] = value;
}
});
// If no active filters, don't show chips
if (Object.keys(activeFilters).length === 0) {
return;
}
// Create chips container
const container = document.createElement('div');
container.id = 'filterChipsContainer';
container.className = 'flex flex-wrap items-center gap-2 mb-4';
// Add filter chips
Object.entries(activeFilters).forEach(([key, value]) => {
const chip = document.createElement('span');
chip.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary/10 dark:bg-primary/20 text-primary border border-primary/20 dark:border-primary/30';
const input = form.querySelector(`[name="${key}"]`);
const label = input?.labels?.[0]?.textContent || key;
chip.innerHTML = `
<span class="font-medium">${label}:</span>
<span class="ml-1">${value}</span>
<button type="button" class="ml-2 hover:text-red-600 transition-colors" data-filter-key="${key}">
<i class="fas fa-times"></i>
</button>
`;
// Add remove listener
chip.querySelector('button').addEventListener('click', () => {
removeFilter(key);
});
container.appendChild(chip);
});
// Add "Clear All" button
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors';
clearAllBtn.innerHTML = '<i class="fas fa-times-circle mr-1"></i> Clear all';
clearAllBtn.addEventListener('click', clearAllFilters);
container.appendChild(clearAllBtn);
// Insert after filter form
const filterBody = document.getElementById('filterBody');
if (filterBody) {
filterBody.appendChild(container);
}
}
function removeFilter(key) {
const form = document.getElementById('projectsFilterForm');
if (!form) return;
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.tagName === 'SELECT') {
input.value = input.options[0].value; // First option (usually "All" or empty)
} else {
input.value = '';
}
}
applyFilters();
}
function clearAllFilters() {
const form = document.getElementById('projectsFilterForm');
if (!form) return;
// Reset all form fields
form.reset();
// Set status to "all"
const statusSelect = form.querySelector('[name="status"]');
if (statusSelect) {
statusSelect.value = 'all';
}
// Clear text inputs
form.querySelectorAll('input[type="text"]').forEach(input => {
input.value = '';
});
// Apply filters immediately
applyFilters();
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('projectsFilterForm');
if (!form) {
console.error('Projects filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on search input (debounced)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
debouncedSearch(500);
});
// Submit on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
} else {
console.error('Search input not found in form');
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (filterTimeout) {
clearTimeout(filterTimeout);
}
applyFilters();
});
// Initial filter chips update
setTimeout(updateFilterChips, 100);
// Export clear function
window.clearProjectsFilters = clearAllFilters;
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,101 @@
<div id="quotesListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found
</h3>
{% if current_user.is_admin or has_permission('create_quotes') %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'quotesBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="quotesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('duplicate')"><i class="fas fa-copy mr-2 text-blue-600"></i>{{ _('Duplicate') }}</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('mark_sent')"><i class="fas fa-paper-plane mr-2 text-green-600"></i>{{ _('Mark as Sent') }}</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('delete')"><i class="fas fa-trash mr-2"></i>{{ _('Delete') }}</a></li>
</ul>
</div>
{% endif %}
</div>
{% if quotes %}
<form method="POST" action="{{ url_for('quotes.bulk_action') }}" id="bulk-action-form" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" id="bulkActionValue" value="">
</form>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin or has_permission('create_quotes') %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllQuotes()">
</th>
{% endif %}
<th class="p-4" data-sortable>{{ _('Quote Number') }}</th>
<th class="p-4" data-sortable>{{ _('Title') }}</th>
<th class="p-4" data-sortable>{{ _('Client') }}</th>
<th class="p-4" data-sortable>{{ _('Amount') }}</th>
<th class="p-4" data-sortable>{{ _('Status') }}</th>
<th class="p-4" data-sortable>{{ _('Created') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for quote in quotes %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin or has_permission('create_quotes') %}
<td class="p-4">
<input type="checkbox" class="quote-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ quote.id }}" onchange="updateQuotesBulkState()">
</td>
{% endif %}
<td class="p-4 font-medium"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.quote_number }}</a></td>
<td class="p-4"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.title }}</a></td>
<td class="p-4">{{ quote.client.name }}</td>
<td class="p-4">
{% if quote.total_amount %}
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
{% if quote.status == 'draft' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ _('Draft') }}</span>
{% elif quote.status == 'sent' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ _('Sent') }}</span>
{% elif quote.status == 'accepted' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ _('Accepted') }}</span>
{% elif quote.status == 'rejected' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Rejected') }}</span>
{% elif quote.status == 'expired' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{{ _('Expired') }}</span>
{% endif %}
</td>
<td class="p-4 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }}
</td>
<td class="p-4">
<a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ _('View') }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
{% if current_user.is_admin or has_permission('create_quotes') %}
<a href="{{ url_for('quotes.create_quote') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>{{ _('Create Your First Quote') }}
</a>
{% endif %}
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>{{ _('Learn More') }}
</a>
{% endset %}
{% if search or status != 'all' %}
{{ empty_state('fas fa-search', 'No Quotes Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new quote that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-file-contract', 'No Quotes Yet', 'Quotes help you manage client proposals and track acceptance rates. Create your first quote to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>

View File

@@ -69,7 +69,7 @@
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" data-filter-form>
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" id="quotesFilterForm" 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" placeholder="{{ _('Search quotes...') }}">
@@ -85,112 +85,12 @@
<option value="expired" {% if status == 'expired' %}selected{% endif %}>{{ _('Expired') }}</option>
</select>
</div>
<div class="col-span-full flex justify-end">
<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>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found
</h3>
{% if current_user.is_admin or has_permission('create_quotes') %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'quotesBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="quotesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('duplicate')"><i class="fas fa-copy mr-2 text-blue-600"></i>{{ _('Duplicate') }}</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('mark_sent')"><i class="fas fa-paper-plane mr-2 text-green-600"></i>{{ _('Mark as Sent') }}</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('delete')"><i class="fas fa-trash mr-2"></i>{{ _('Delete') }}</a></li>
</ul>
</div>
{% endif %}
</div>
{% if quotes %}
<form method="POST" action="{{ url_for('quotes.bulk_action') }}" id="bulk-action-form" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" id="bulkActionValue" value="">
</form>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin or has_permission('create_quotes') %}
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllQuotes()">
</th>
{% endif %}
<th class="p-4" data-sortable>{{ _('Quote Number') }}</th>
<th class="p-4" data-sortable>{{ _('Title') }}</th>
<th class="p-4" data-sortable>{{ _('Client') }}</th>
<th class="p-4" data-sortable>{{ _('Amount') }}</th>
<th class="p-4" data-sortable>{{ _('Status') }}</th>
<th class="p-4" data-sortable>{{ _('Created') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for quote in quotes %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
{% if current_user.is_admin or has_permission('create_quotes') %}
<td class="p-4">
<input type="checkbox" class="quote-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ quote.id }}" onchange="updateQuotesBulkState()">
</td>
{% endif %}
<td class="p-4 font-medium"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.quote_number }}</a></td>
<td class="p-4"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.title }}</a></td>
<td class="p-4">{{ quote.client.name }}</td>
<td class="p-4">
{% if quote.total_amount %}
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
{% if quote.status == 'draft' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ _('Draft') }}</span>
{% elif quote.status == 'sent' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ _('Sent') }}</span>
{% elif quote.status == 'accepted' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ _('Accepted') }}</span>
{% elif quote.status == 'rejected' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Rejected') }}</span>
{% elif quote.status == 'expired' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{{ _('Expired') }}</span>
{% endif %}
</td>
<td class="p-4 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }}
</td>
<td class="p-4">
<a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ _('View') }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% set actions %}
{% if current_user.is_admin or has_permission('create_quotes') %}
<a href="{{ url_for('quotes.create_quote') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>{{ _('Create Your First Quote') }}
</a>
{% endif %}
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>{{ _('Learn More') }}
</a>
{% endset %}
{% if search or status != 'all' %}
{{ empty_state('fas fa-search', 'No Quotes Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new quote that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-file-contract', 'No Quotes Yet', 'Quotes help you manage client proposals and track acceptance rates. Create your first quote to get started!', actions, type='no-data') }}
{% endif %}
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="quotesContainer">
{% include 'quotes/_quotes_list.html' %}
</div>
{% endblock %}
@@ -245,6 +145,179 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
// Quotes Filter Handler - AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('quotesFilterForm');
if (!form) return {};
const params = {};
// Get search input value directly
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
if (searchValue) {
params.search = searchValue;
}
}
// Get status
const statusSelect = form.querySelector('[name="status"]');
if (statusSelect) {
const statusValue = statusSelect.value;
if (statusValue && statusValue !== 'all') {
params.status = statusValue;
} else {
params.status = 'all';
}
} else {
params.status = 'all';
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/quotes?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('quotesListContainer');
if (!container) {
console.error('quotesListContainer not found');
return;
}
// Show loading state
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
// Update URL
if (window.history && window.history.pushState) {
window.history.pushState({}, '', url);
}
// Fetch filtered results
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html.trim();
const newContainer = tempDiv.querySelector('#quotesListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
const match = html.trim().match(/<div[^>]*id=["']quotesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
}
}
})
.catch(error => {
console.error('Filter error:', error);
container.style.opacity = '';
container.style.pointerEvents = '';
if (window.toastManager) {
window.toastManager.show('Failed to filter quotes. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter quotes. Please refresh the page.', 'error');
}
})
.finally(() => {
container.style.opacity = '';
container.style.pointerEvents = '';
});
}
function debouncedApplyFilters(delay = 100) {
if (filterTimeout) {
clearTimeout(filterTimeout);
}
filterTimeout = setTimeout(applyFilters, delay);
}
function debouncedSearch(delay = 500) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(applyFilters, delay);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('quotesFilterForm');
if (!form) {
console.error('Quotes filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on search input (debounced)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
searchInput.addEventListener('input', () => {
debouncedSearch(500);
});
// Submit on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (filterTimeout) {
clearTimeout(filterTimeout);
}
applyFilters();
});
});
})();
{% if current_user.is_admin or has_permission('create_quotes') %}
function toggleAllQuotes(){
const selectAll = document.getElementById('selectAll');

View File

@@ -241,10 +241,12 @@ function previewReport() {
}
// Collect current filters
const projectSelect = document.getElementById('filterProject');
const projectValue = projectSelect ? projectSelect.value : '';
reportConfig.filters = {
start_date: document.getElementById('filterStartDate').value,
end_date: document.getElementById('filterEndDate').value,
project_id: document.getElementById('filterProject').value || null
start_date: document.getElementById('filterStartDate').value || null,
end_date: document.getElementById('filterEndDate').value || null,
project_id: projectValue || null
};
// Show preview modal
@@ -259,17 +261,55 @@ function previewReport() {
error.classList.add('hidden');
data.classList.add('hidden');
// Debug: Log what we're sending
console.log('Preview report config:', reportConfig);
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
// Fetch preview data
fetch('{{ url_for("custom_reports.preview_report") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
config: reportConfig
})
})
.then(response => response.json())
.then(async response => {
// Check if response is OK first
if (!response.ok) {
// Clone before reading error response
const errorResponseClone = response.clone();
// Try to get error message from response
let errorMsg = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await errorResponseClone.json();
errorMsg = errorData.message || errorData.error || errorMsg;
console.error('Preview error response:', errorData);
} catch (parseError) {
// If JSON parsing fails, try to get text from a new clone
try {
const textClone = response.clone();
const errorText = await textClone.text();
console.error('Preview error text:', errorText);
if (errorText) {
errorMsg = errorText;
}
} catch (textError) {
console.error('Could not parse error response:', textError);
}
}
throw new Error(errorMsg);
}
// Response is OK, parse JSON
const result = await response.json();
return result;
})
.then(result => {
loading.classList.add('hidden');
@@ -277,14 +317,18 @@ function previewReport() {
renderPreview(result.data);
data.classList.remove('hidden');
} else {
errorMessage.textContent = result.message || '{{ _("Failed to generate preview") }}';
const msg = result.message || '{{ _("Failed to generate preview") }}';
errorMessage.textContent = msg;
error.classList.remove('hidden');
console.error('Preview failed:', result);
}
})
.catch(err => {
loading.classList.add('hidden');
errorMessage.textContent = '{{ _("Error loading preview") }}: ' + err.message;
const msg = err.message || '{{ _("Unknown error occurred") }}';
errorMessage.textContent = '{{ _("Error loading preview") }}: ' + msg;
error.classList.remove('hidden');
console.error('Preview error:', err);
});
}
@@ -384,11 +428,16 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => {
};
try {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const response = await fetch('{{ url_for("custom_reports.save_report_view") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
name: name,
scope: scope,

View File

@@ -0,0 +1,103 @@
<div id="tasksListContainer">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<i class="fas fa-download mr-1"></i> Export
</a>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60 btn-press transition-smooth" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllTasks()">
</th>
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Project</th>
<th class="p-4" data-sortable>Priority</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4 table-number" data-sortable>Due</th>
<th class="p-4 table-number" data-sortable>Progress</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-context-menu='[{"label": "View", "icon": "fas fa-eye", "action": {"type": "view", "url": "{{ url_for('tasks.view_task', task_id=task.id) }}"}}, {"label": "Edit", "icon": "fas fa-edit", "action": {"type": "edit", "url": "{{ url_for('tasks.edit_task', task_id=task.id) }}"}}, {"separator": true}, {"label": "Delete", "icon": "fas fa-trash", "danger": true, "action": {"type": "delete", "url": "{{ url_for('tasks.delete_task', task_id=task.id) }}", "confirm": "Are you sure you want to delete this task?"}}]'>
<td class="p-4">
<input type="checkbox" class="task-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary transition-all" value="{{ task.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="p-4"><a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ task.name }}</a></td>
<td class="p-4"><a href="{{ url_for('projects.view_project', project_id=task.project_id) }}" class="text-primary hover:underline">{{ task.project.name }}</a></td>
<td class="p-4">
{% set p = task.priority %}
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ pcls }}">{{ task.priority_display }}</span>
</td>
<td class="p-4">
{% set s = task.status %}
{% set statusColors = {'todo': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
'in_progress': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
'review': 'status-pending',
'done': 'status-active',
'cancelled': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'} %}
{% set scls = statusColors.get(s, 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ scls }}">{{ task.status_display }}</span>
</td>
<td class="p-4 table-number">
{% if task.due_date %}
{% set overdue = task.is_overdue %}
<span class="chip whitespace-nowrap {{ 'status-overdue' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4 table-number">
{% set pct = task.progress_percentage or 0 %}
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
</div>
</td>
<td class="p-4">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not tasks %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors btn-press shadow-md">
<i class="fas fa-plus mr-2"></i>Create Your First Task
</a>
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if search or status or priority or project_id or assigned_to %}
{{ empty_state('fas fa-search', 'No Tasks Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new task that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Tasks help you break down projects into manageable pieces. Create your first task to get started organizing your work!', actions, type='no-data') }}
{% endif %}
{% endif %}
</div>

View File

@@ -35,7 +35,7 @@
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-filter-form>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="tasksFilterForm" data-filter-form>
<div class="lg:col-span-1">
<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">
@@ -83,117 +83,12 @@
<input type="checkbox" name="overdue" id="overdue" value="1" {% if overdue %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="overdue" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Overdue only</label>
</div>
<div class="col-span-full flex justify-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
</div>
</form>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<i class="fas fa-download mr-1"></i> Export
</a>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60 btn-press transition-smooth" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
</div>
</div>
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllTasks()">
</th>
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Project</th>
<th class="p-4" data-sortable>Priority</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4 table-number" data-sortable>Due</th>
<th class="p-4 table-number" data-sortable>Progress</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-context-menu='[{"label": "View", "icon": "fas fa-eye", "action": {"type": "view", "url": "{{ url_for('tasks.view_task', task_id=task.id) }}"}}, {"label": "Edit", "icon": "fas fa-edit", "action": {"type": "edit", "url": "{{ url_for('tasks.edit_task', task_id=task.id) }}"}}, {"separator": true}, {"label": "Delete", "icon": "fas fa-trash", "danger": true, "action": {"type": "delete", "url": "{{ url_for('tasks.delete_task', task_id=task.id) }}", "confirm": "Are you sure you want to delete this task?"}}]'>
<td class="p-4">
<input type="checkbox" class="task-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary transition-all" value="{{ task.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="p-4"><a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ task.name }}</a></td>
<td class="p-4"><a href="{{ url_for('projects.view_project', project_id=task.project_id) }}" class="text-primary hover:underline">{{ task.project.name }}</a></td>
<td class="p-4">
{% set p = task.priority %}
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ pcls }}">{{ task.priority_display }}</span>
</td>
<td class="p-4">
{% set s = task.status %}
{% set statusColors = {'todo': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
'in_progress': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
'review': 'status-pending',
'done': 'status-active',
'cancelled': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'} %}
{% set scls = statusColors.get(s, 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ scls }}">{{ task.status_display }}</span>
</td>
<td class="p-4 table-number">
{% if task.due_date %}
{% set overdue = task.is_overdue %}
<span class="chip whitespace-nowrap {{ 'status-overdue' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4 table-number">
{% set pct = task.progress_percentage or 0 %}
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
</div>
</td>
<td class="p-4">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not tasks %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors btn-press shadow-md">
<i class="fas fa-plus mr-2"></i>Create Your First Task
</a>
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if search or status or priority or project_id or assigned_to %}
{{ empty_state('fas fa-search', 'No Tasks Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new task that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Tasks help you break down projects into manageable pieces. Create your first task to get started organizing your work!', actions, type='no-data') }}
{% endif %}
{% endif %}
</tbody>
</table>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="tasksContainer">
{% include 'tasks/_tasks_list.html' %}
</div>
<!-- Bulk Operations Forms (hidden) -->
@@ -573,5 +468,182 @@ document.addEventListener('DOMContentLoaded', function() {
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
// Tasks Filter Handler - AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('tasksFilterForm');
if (!form) return {};
const params = {};
// Get search input value directly
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
if (searchValue) {
params.search = searchValue;
}
}
// Get other form fields from FormData
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
if (key === 'search') continue;
const trimmed = String(value || '').trim();
if (trimmed && trimmed !== '') {
params[key] = trimmed;
}
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/tasks?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('tasksListContainer');
if (!container) {
console.error('tasksListContainer not found');
return;
}
// Show loading state
container.style.opacity = '0.5';
container.style.pointerEvents = 'none';
// Update URL
if (window.history && window.history.pushState) {
window.history.pushState({}, '', url);
}
// Fetch filtered results
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html.trim();
const newContainer = tempDiv.querySelector('#tasksListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
const match = html.trim().match(/<div[^>]*id=["']tasksListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
}
}
})
.catch(error => {
console.error('Filter error:', error);
container.style.opacity = '';
container.style.pointerEvents = '';
if (window.toastManager) {
window.toastManager.show('Failed to filter tasks. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter tasks. Please refresh the page.', 'error');
}
})
.finally(() => {
container.style.opacity = '';
container.style.pointerEvents = '';
});
}
function debouncedApplyFilters(delay = 100) {
if (filterTimeout) {
clearTimeout(filterTimeout);
}
filterTimeout = setTimeout(applyFilters, delay);
}
function debouncedSearch(delay = 500) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(applyFilters, delay);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('tasksFilterForm');
if (!form) {
console.error('Tasks filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on checkbox changes
form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on search input (debounced)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
searchInput.addEventListener('input', () => {
debouncedSearch(500);
});
// Submit on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (filterTimeout) {
clearTimeout(filterTimeout);
}
applyFilters();
});
});
})();
</script>
{% endblock %}

View File

@@ -74,22 +74,22 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Date') }}</label>
<input type="date" name="start_date" id="start_date" required class="form-input">
<input type="date" name="start_date" id="start_date" required class="form-input" value="{{ prefill_start_date or '' }}">
</div>
<div>
<label for="start_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Time') }}</label>
<input type="time" name="start_time" id="start_time" required class="form-input">
<input type="time" name="start_time" id="start_time" required class="form-input" value="{{ prefill_start_time or '' }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Date') }}</label>
<input type="date" name="end_date" id="end_date" required class="form-input">
<input type="date" name="end_date" id="end_date" required class="form-input" value="{{ prefill_end_date or '' }}">
</div>
<div>
<label for="end_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Time') }}</label>
<input type="time" name="end_time" id="end_time" required class="form-input">
<input type="time" name="end_time" id="end_time" required class="form-input" value="{{ prefill_end_time or '' }}">
</div>
</div>
@@ -197,18 +197,82 @@ document.addEventListener('DOMContentLoaded', async function(){
if (projectSelect.value){ loadTasks(projectSelect.value); }
}
// Form validation: ensure either project or client is selected
// Form validation: ensure either project or client is selected and validate time range
// Use capture phase to run before other handlers
const form = document.getElementById('manualEntryForm');
if (form) {
// Store original button state to restore if needed
const submitBtn = form.querySelector('button[type="submit"]');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
form.addEventListener('submit', function(e) {
// Validate project or client selection
const projectVal = projectSelect ? projectSelect.value : '';
const clientVal = clientSelect ? clientSelect.value : '';
if (!projectVal && !clientVal) {
e.preventDefault();
alert('{{ _("Please select either a project or a client") }}');
e.stopImmediatePropagation(); // Stop other handlers from running
// Ensure button state is preserved
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
// Show error message using toast notification
const errorMsg = '{{ _("Please select either a project or a client") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else {
alert(errorMsg);
}
// After showing error, ensure button is still in correct state
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
return false;
}
});
// Validate time range before submission
const startDate = document.getElementById('start_date');
const startTime = document.getElementById('start_time');
const endDate = document.getElementById('end_date');
const endTime = document.getElementById('end_time');
if (startDate && startTime && endDate && endTime &&
startDate.value && startTime.value && endDate.value && endTime.value) {
const start = new Date(`${startDate.value}T${startTime.value}:00`);
const end = new Date(`${endDate.value}T${endTime.value}:00`);
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
if (end <= start) {
e.preventDefault();
e.stopImmediatePropagation(); // Stop other handlers from running
// Ensure button state is preserved
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
// Show error message using toast notification
const errorMsg = '{{ _("End time must be after start time") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else {
alert(errorMsg);
}
// After showing error, ensure button is still in correct state
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
return false;
}
}
}
}, true); // Use capture phase to run before other handlers
}
// Apply Time Entry Template if provided via sessionStorage or query param

View File

@@ -297,6 +297,44 @@ if (projectSelectEl && clientSelectEl) {
});
}
// Form validation: ensure either project or client is selected
const timerStartForm = document.getElementById('timer-start-form');
if (timerStartForm) {
// Store original button state to restore if needed
const submitBtn = timerStartForm.querySelector('button[type="submit"]');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
timerStartForm.addEventListener('submit', function(e) {
// Validate project or client selection
const projectVal = projectSelectEl ? projectSelectEl.value : '';
const clientVal = clientSelectEl ? clientSelectEl.value : '';
if (!projectVal && !clientVal) {
e.preventDefault();
e.stopImmediatePropagation(); // Stop other handlers from running
// Ensure button state is preserved
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
// Show error message using toast notification
const errorMsg = '{{ _("Please select either a project or a client") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else {
alert(errorMsg);
}
// After showing error, ensure button is still in correct state
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
return false;
}
}, true); // Use capture phase to run before other handlers
}
// Select recent project
function selectRecentProject(projectId, projectName) {
const projectSelect = document.getElementById('project_id');

View File

@@ -42,6 +42,13 @@ class ConfigManager:
# Check environment variable second (.env file - used as initial values)
env_value = os.getenv(key.upper())
if env_value is not None:
# Convert string booleans to actual booleans for consistency
if isinstance(env_value, str):
lower_val = env_value.lower().strip()
if lower_val in ("true", "1", "yes", "on"):
return True
elif lower_val in ("false", "0", "no", "off", ""):
return False
return env_value
# Check app config

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.1.2',
version='4.2.0',
packages=find_packages(),
include_package_data=True,
install_requires=[