diff --git a/app/routes/auth.py b/app/routes/auth.py
index 75169d8..7bf016a 100644
--- a/app/routes/auth.py
+++ b/app/routes/auth.py
@@ -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"
diff --git a/app/routes/clients.py b/app/routes/clients.py
index 7ffcd51..2578978 100644
--- a/app/routes/clients.py
+++ b/app/routes/clients.py
@@ -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)
diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py
index 09fff3b..d4254df 100644
--- a/app/routes/custom_reports.py
+++ b/app/routes/custom_reports.py
@@ -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
],
diff --git a/app/routes/invoices.py b/app/routes/invoices.py
index 8ca07fc..195deef 100644
--- a/app/routes/invoices.py
+++ b/app/routes/invoices.py
@@ -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"])
diff --git a/app/routes/projects.py b/app/routes/projects.py
index 573959a..6e0c8fe 100644
--- a/app/routes/projects.py
+++ b/app/routes/projects.py
@@ -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)
diff --git a/app/routes/quotes.py b/app/routes/quotes.py
index 82373f8..ac903b6 100644
--- a/app/routes/quotes.py
+++ b/app/routes/quotes.py
@@ -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,
diff --git a/app/routes/tasks.py b/app/routes/tasks.py
index 87e35d8..5ed914c 100644
--- a/app/routes/tasks.py
+++ b/app/routes/tasks.py
@@ -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",
diff --git a/app/routes/timer.py b/app/routes/timer.py
index 544d910..964cb9f 100644
--- a/app/routes/timer.py
+++ b/app/routes/timer.py
@@ -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"))
diff --git a/app/services/project_service.py b/app/services/project_service.py
index ac12cda..6107f4a 100644
--- a/app/services/project_service.py
+++ b/app/services/project_service.py
@@ -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)
diff --git a/app/static/enhanced-ui.js b/app/static/enhanced-ui.js
index 12ab620..ed57121 100644
--- a/app/static/enhanced-ui.js
+++ b/app/static/enhanced-ui.js
@@ -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:
...
+ // 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(/
]*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);
});
diff --git a/app/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js
index 57890c1..e5e4278 100644
--- a/app/static/error-handling-enhanced.js
+++ b/app/static/error-handling-enhanced.js
@@ -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;
}
diff --git a/app/static/form-validation.js b/app/static/form-validation.js
index 20036ad..35f01fd 100644
--- a/app/static/form-validation.js
+++ b/app/static/form-validation.js
@@ -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;
diff --git a/app/templates/base.html b/app/templates/base.html
index 8ef5687..fa8abb5 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -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") }}'
}
};
diff --git a/app/templates/clients/_clients_list.html b/app/templates/clients/_clients_list.html
new file mode 100644
index 0000000..0445a85
--- /dev/null
+++ b/app/templates/clients/_clients_list.html
@@ -0,0 +1,106 @@
+
+
+
+ {{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
+
+ {% if not clients %}
+ {% from "components/ui.html" import empty_state %}
+ {% set actions %}
+ {% if current_user.is_admin %}
+
+ Create Your First Client
+
+ {% endif %}
+
+ Learn More
+
+ {% 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 %}
+
- {% if not clients %}
- {% from "components/ui.html" import empty_state %}
- {% set actions %}
- {% if current_user.is_admin %}
-
- Create Your First Client
-
- {% endif %}
-
- Learn More
-
- {% 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 %}
+
+ {% include 'clients/_clients_list.html' %}
{% 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(/
+ {% if not invoices %}
+ {% from "components/ui.html" import empty_state %}
+ {% set actions %}
+
+ Create Your First Invoice
+
+
+ Learn More
+
+ {% 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 %}
+
- {% if not invoices %}
- {% from "components/ui.html" import empty_state %}
- {% set actions %}
-
- Create Your First Invoice
-
-
- Learn More
-
- {% 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 %}
+
+
+
+
+ {% if current_user.is_admin or has_permission('edit_projects') %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {{ project.name }}
+
+
+
+ {{ project.status|title }}
+
+
+
+ {% if project.client %}
+
+ {{ project.client }}
+
+ {% endif %}
+
+ {% if project.description %}
+
+ {{ project.description | markdown | safe }}
+
+ {% endif %}
+
+
+
+
+ {% 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 not projects %}
+ {% from "components/ui.html" import empty_state %}
+ {% set actions %}
+ {% if current_user.is_admin %}
+
+ Create Your First Project
+
+ {% endif %}
+
+ Learn More
+
+ {% 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 %}
+
{{ 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 %}
+
{% 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(/
]*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 = `
+ ${label}:
+ ${value}
+
+ `;
+
+ // 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 = ' 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;
+ });
+})();
{% endblock %}
diff --git a/app/templates/quotes/_quotes_list.html b/app/templates/quotes/_quotes_list.html
new file mode 100644
index 0000000..175a178
--- /dev/null
+++ b/app/templates/quotes/_quotes_list.html
@@ -0,0 +1,101 @@
+
+
+
+ {{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found
+
+ {% if current_user.is_admin or has_permission('create_quotes') %}
+
+ {% else %}
+ {% from "components/ui.html" import empty_state %}
+ {% set actions %}
+ {% if current_user.is_admin or has_permission('create_quotes') %}
+
+ {{ _('Create Your First Quote') }}
+
+ {% endif %}
+
+ {{ _('Learn More') }}
+
+ {% 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 %}
+
- {% else %}
- {% set actions %}
- {% if current_user.is_admin or has_permission('create_quotes') %}
-
- {{ _('Create Your First Quote') }}
-
- {% endif %}
-
- {{ _('Learn More') }}
-
- {% 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 %}
+
+ {% if not tasks %}
+ {% from "components/ui.html" import empty_state %}
+ {% set actions %}
+
+ Create Your First Task
+
+
+ Learn More
+
+ {% 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 %}
+
- {% if not tasks %}
- {% from "components/ui.html" import empty_state %}
- {% set actions %}
-
- Create Your First Task
-
-
- Learn More
-
- {% 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 %}
-
-
+