From ac465d961251f443e607f9bc3e34c78b1e7b290b Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 30 Nov 2025 10:51:09 +0100 Subject: [PATCH] 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 --- app/routes/auth.py | 40 ++- app/routes/clients.py | 14 +- app/routes/custom_reports.py | 51 ++- app/routes/invoices.py | 12 +- app/routes/projects.py | 44 ++- app/routes/quotes.py | 13 + app/routes/tasks.py | 17 + app/routes/timer.py | 249 ++++++++++++--- app/services/project_service.py | 20 +- app/static/enhanced-ui.js | 290 ++++++++++++++++- app/static/error-handling-enhanced.js | 6 +- app/static/form-validation.js | 7 + app/templates/base.html | 4 +- app/templates/clients/_clients_list.html | 106 +++++++ app/templates/clients/list.html | 287 ++++++++++------- app/templates/invoices/_invoices_list.html | 157 ++++++++++ app/templates/invoices/list.html | 345 +++++++++++---------- app/templates/main/dashboard.html | 140 ++++++--- app/templates/projects/_projects_list.html | 295 ++++++++++++++++++ app/templates/projects/list.html | 319 ++++++++++++++++++- app/templates/quotes/_quotes_list.html | 101 ++++++ app/templates/quotes/list.html | 279 +++++++++++------ app/templates/reports/builder.html | 61 +++- app/templates/tasks/_tasks_list.html | 103 ++++++ app/templates/tasks/list.html | 288 ++++++++++------- app/templates/timer/manual_entry.html | 78 ++++- app/templates/timer/timer_page.html | 38 +++ app/utils/config_manager.py | 7 + setup.py | 2 +- 29 files changed, 2727 insertions(+), 646 deletions(-) create mode 100644 app/templates/clients/_clients_list.html create mode 100644 app/templates/invoices/_invoices_list.html create mode 100644 app/templates/projects/_projects_list.html create mode 100644 app/templates/quotes/_quotes_list.html create mode 100644 app/templates/tasks/_tasks_list.html 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 +

+
+ + Export + + {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+ + + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + + + {% for client in clients %} + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + {% else %} + {% endfor %} + +
+ + NameContact PersonEmailStatusProjectsPortalActions
+ + {{ client.name }}{{ client.contact_person or '—' }} + {% if client.email %} + {{ client.email }} + {% else %} + — + {% endif %} + + {% if client.status == 'active' %} + Active + {% else %} + Inactive + {% endif %} + + {{ client.active_projects }}/{{ client.total_projects }} + + {% if client.portal_enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + + View +
+ {% 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 %} +
diff --git a/app/templates/clients/list.html b/app/templates/clients/list.html index 7ef42ee..ce2fa11 100644 --- a/app/templates/clients/list.html +++ b/app/templates/clients/list.html @@ -22,7 +22,7 @@
-
+
@@ -35,118 +35,12 @@
-
- -
-
-
-

- {{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found -

-
- - Export - - {% if current_user.is_admin %} -
- - -
- {% endif %} -
-
- - - - {% if current_user.is_admin %} - - {% endif %} - - - - - - - - - - - {% for client in clients %} - - {% if current_user.is_admin %} - - {% endif %} - - - - - - - - - {% else %} - {% endfor %} - -
- - NameContact PersonEmailStatusProjectsPortalActions
- - {{ client.name }}{{ client.contact_person or '—' }} - {% if client.email %} - {{ client.email }} - {% else %} - — - {% endif %} - - {% if client.status == 'active' %} - Active - {% else %} - Inactive - {% endif %} - - {{ client.active_projects }}/{{ client.total_projects }} - - {% if client.portal_enabled %} - - Enabled - - {% else %} - - Disabled - - {% endif %} - - View -
- {% 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(/]*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'); diff --git a/app/templates/invoices/_invoices_list.html b/app/templates/invoices/_invoices_list.html new file mode 100644 index 0000000..519b6e8 --- /dev/null +++ b/app/templates/invoices/_invoices_list.html @@ -0,0 +1,157 @@ +
+
+

+ {{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found +

+
+ + Export + + {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+ + + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + + + {% for invoice in invoices %} + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + {% else %} + {% endfor %} + +
+ + NumberClientStatusPaymentTotalDue DateActions
+ + {{ invoice.invoice_number }}{{ invoice.client_name }} + {% set s = invoice.status %} + {% if s == 'draft' %} + + Draft + + {% elif s == 'sent' %} + + Sent + + {% elif s == 'paid' %} + + Paid + + {% elif s == 'overdue' %} + + Overdue + + {% else %} + {{ s }} + {% endif %} + + {% set ps = invoice.payment_status %} + {% if ps == 'unpaid' %} + + Unpaid + + {% elif ps == 'partially_paid' %} + + Partial + + {% elif ps == 'fully_paid' %} + + Paid + + {% elif ps == 'overpaid' %} + + Overpaid + + {% else %} + {{ ps }} + {% endif %} + {{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }} + {% if invoice.due_date %} + {% set is_overdue = invoice._is_overdue %} + + {% if is_overdue %}{% endif %} + {{ invoice.due_date.strftime('%Y-%m-%d') }} + + {% else %} + + {% 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 %} +
diff --git a/app/templates/invoices/list.html b/app/templates/invoices/list.html index 609f31f..dc328c8 100644 --- a/app/templates/invoices/list.html +++ b/app/templates/invoices/list.html @@ -35,7 +35,7 @@
-
+
@@ -71,169 +71,12 @@
-
- -
-
-
-

- {{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found -

-
- - Export - - {% if current_user.is_admin %} -
- - -
- {% endif %} -
-
- - - - {% if current_user.is_admin %} - - {% endif %} - - - - - - - - - - - {% for invoice in invoices %} - - {% if current_user.is_admin %} - - {% endif %} - - - - - - - - - {% else %} - {% endfor %} - -
- - NumberClientStatusPaymentTotalDue DateActions
- - {{ invoice.invoice_number }}{{ invoice.client_name }} - {% set s = invoice.status %} - {% if s == 'draft' %} - - Draft - - {% elif s == 'sent' %} - - Sent - - {% elif s == 'paid' %} - - Paid - - {% elif s == 'overdue' %} - - Overdue - - {% else %} - {{ s }} - {% endif %} - - {% set ps = invoice.payment_status %} - {% if ps == 'unpaid' %} - - Unpaid - - {% elif ps == 'partially_paid' %} - - Partial - - {% elif ps == 'fully_paid' %} - - Paid - - {% elif ps == 'overpaid' %} - - Overpaid - - {% else %} - {{ ps }} - {% endif %} - {{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }} - {% if invoice.due_date %} - {% set is_overdue = invoice._is_overdue %} - - {% if is_overdue %}{% endif %} - {{ invoice.due_date.strftime('%Y-%m-%d') }} - - {% else %} - - {% 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 %} +
+ {% include 'invoices/_invoices_list.html' %}
@@ -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(/]*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() { diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index b1a9d15..9a33106 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -153,60 +153,62 @@
- {% if current_week_goal %} -
-
-

- - {{ _('Weekly Goal') }} -

- - - + {% if settings and settings.ui_allow_weekly_goals and current_user.ui_show_weekly_goals %} + {% if current_week_goal %} +
+
+

+ + {{ _('Weekly Goal') }} +

+ + + +
+
+
+ {{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h + {{ current_week_goal.progress_percentage }}% +
+
+
+
+
+
+
+
{{ _('Remaining') }}
+
{{ current_week_goal.remaining_hours }}h
+
+
+
{{ _('Days Left') }}
+
{{ current_week_goal.days_remaining }}
+
+
+ {% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %} +
+ + {{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }} +
+ {% endif %}
-
-
- {{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h - {{ current_week_goal.progress_percentage }}% + {% else %} +
+
+ +

+ {{ _('No Weekly Goal') }} +

+

+ {{ _('Set a weekly time goal to track your progress') }} +

+ + {{ _('Create Goal') }} +
-
-
-
-
-
-
-
{{ _('Remaining') }}
-
{{ current_week_goal.remaining_hours }}h
-
-
-
{{ _('Days Left') }}
-
{{ current_week_goal.days_remaining }}
-
-
- {% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %} -
- - {{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
{% endif %} -
- {% else %} -
-
- -

- {{ _('No Weekly Goal') }} -

-

- {{ _('Set a weekly time goal to track your progress') }} -

- - {{ _('Create Goal') }} - -
-
{% endif %}
@@ -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 diff --git a/app/templates/projects/_projects_list.html b/app/templates/projects/_projects_list.html new file mode 100644 index 0000000..27fade2 --- /dev/null +++ b/app/templates/projects/_projects_list.html @@ -0,0 +1,295 @@ +
+
+

+ {{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found +

+
+ +
+ + +
+ + Export + + {% if current_user.is_admin %} +
+ + +
+ {% endif %} +
+
+ + +
+ + + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + + + + {% for project in projects %} + + {% if current_user.is_admin %} + + {% endif %} + + + + + + + + + + {% else %} + {% endfor %} + +
+ + NameClientStatusBillableRateBudgetActions
+ + + {% set is_fav = favorite_project_ids and project.id in favorite_project_ids %} + + {{ project.name }} + {% if project.client_id %} + {{ project.client }} + {% else %} + {{ project.client }} + {% endif %} + + {% if project.status == 'active' %} + Active + {% elif project.status == 'inactive' %} + Inactive + {% else %} + Archived + {% endif %} + + {% if project.billable %} + Billable + {% else %} + Non-billable + {% endif %} + + {% if project.hourly_rate %} + {{ '%.2f'|format(project.hourly_rate|float) }}/h + {% else %} + + {% 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 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 %} + {{ pct|round(0) }}% + {% else %} + + {% endif %} + + View +
+
+ + + + + {% 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 %} +
diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html index 57add60..f0b111f 100644 --- a/app/templates/projects/list.html +++ b/app/templates/projects/list.html @@ -22,7 +22,7 @@
-
+
@@ -30,7 +30,7 @@
-
- -
-
+
+

{{ 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') %} +
+ + +
+ {% endif %} +
+ {% if quotes %} + + + + + {% if current_user.is_admin or has_permission('create_quotes') %} + + {% endif %} + + + + + + + + + + + {% for quote in quotes %} + + {% if current_user.is_admin or has_permission('create_quotes') %} + + {% endif %} + + + + + + + + + {% endfor %} + +
+ + {{ _('Quote Number') }}{{ _('Title') }}{{ _('Client') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Created') }}{{ _('Actions') }}
+ + {{ quote.quote_number }}{{ quote.title }}{{ quote.client.name }} + {% if quote.total_amount %} + {{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }} + {% else %} + + {% endif %} + + {% if quote.status == 'draft' %} + {{ _('Draft') }} + {% elif quote.status == 'sent' %} + {{ _('Sent') }} + {% elif quote.status == 'accepted' %} + {{ _('Accepted') }} + {% elif quote.status == 'rejected' %} + {{ _('Rejected') }} + {% elif quote.status == 'expired' %} + {{ _('Expired') }} + {% endif %} + + {{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }} + + {{ _('View') }} +
+ {% 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 %} +
diff --git a/app/templates/quotes/list.html b/app/templates/quotes/list.html index 2b3e15a..be7946b 100644 --- a/app/templates/quotes/list.html +++ b/app/templates/quotes/list.html @@ -69,7 +69,7 @@
-
+
@@ -85,112 +85,12 @@
-
- -
-
-
-

- {{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found -

- {% if current_user.is_admin or has_permission('create_quotes') %} -
- - -
- {% endif %} -
- {% if quotes %} - - - - - {% if current_user.is_admin or has_permission('create_quotes') %} - - {% endif %} - - - - - - - - - - - {% for quote in quotes %} - - {% if current_user.is_admin or has_permission('create_quotes') %} - - {% endif %} - - - - - - - - - {% endfor %} - -
- - {{ _('Quote Number') }}{{ _('Title') }}{{ _('Client') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Created') }}{{ _('Actions') }}
- - {{ quote.quote_number }}{{ quote.title }}{{ quote.client.name }} - {% if quote.total_amount %} - {{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }} - {% else %} - - {% endif %} - - {% if quote.status == 'draft' %} - {{ _('Draft') }} - {% elif quote.status == 'sent' %} - {{ _('Sent') }} - {% elif quote.status == 'accepted' %} - {{ _('Accepted') }} - {% elif quote.status == 'rejected' %} - {{ _('Rejected') }} - {% elif quote.status == 'expired' %} - {{ _('Expired') }} - {% endif %} - - {{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }} - - {{ _('View') }} -
- {% 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 %} +
+ {% include 'quotes/_quotes_list.html' %}
{% 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(/]*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'); diff --git a/app/templates/reports/builder.html b/app/templates/reports/builder.html index 28d12fc..648202f 100644 --- a/app/templates/reports/builder.html +++ b/app/templates/reports/builder.html @@ -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, diff --git a/app/templates/tasks/_tasks_list.html b/app/templates/tasks/_tasks_list.html new file mode 100644 index 0000000..cab4ae9 --- /dev/null +++ b/app/templates/tasks/_tasks_list.html @@ -0,0 +1,103 @@ +
+
+

+ {{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found +

+
+ + Export + +
+ + +
+
+
+ + + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + + + {% else %} + {% endfor %} + +
+ + NameProjectPriorityStatusDueProgressActions
+ + {{ task.name }}{{ task.project.name }} + {% 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' %} + {{ task.priority_display }} + + {% 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') %} + {{ task.status_display }} + + {% if task.due_date %} + {% set overdue = task.is_overdue %} + {{ task.due_date.strftime('%Y-%m-%d') }} + {% else %} + + {% endif %} + + {% set pct = task.progress_percentage or 0 %} +
+
+
+
+ View +
+ {% 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 %} +
diff --git a/app/templates/tasks/list.html b/app/templates/tasks/list.html index bffc396..96483b6 100644 --- a/app/templates/tasks/list.html +++ b/app/templates/tasks/list.html @@ -35,7 +35,7 @@
-
+
@@ -83,117 +83,12 @@
-
- -
-
-
-

- {{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found -

-
- - Export - -
- - -
-
-
- - - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - - - {% else %} - {% endfor %} - -
- - NameProjectPriorityStatusDueProgressActions
- - {{ task.name }}{{ task.project.name }} - {% 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' %} - {{ task.priority_display }} - - {% 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') %} - {{ task.status_display }} - - {% if task.due_date %} - {% set overdue = task.is_overdue %} - {{ task.due_date.strftime('%Y-%m-%d') }} - {% else %} - - {% endif %} - - {% set pct = task.progress_percentage or 0 %} -
-
-
-
- View -
- {% 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 %} - - +
+ {% include 'tasks/_tasks_list.html' %}
@@ -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(/]*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(); + }); + }); +})(); {% endblock %} \ No newline at end of file diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index 8e605ff..ad226a5 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -74,22 +74,22 @@
- +
- +
- +
- +
@@ -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 diff --git a/app/templates/timer/timer_page.html b/app/templates/timer/timer_page.html index 4c067ef..9392c66 100644 --- a/app/templates/timer/timer_page.html +++ b/app/templates/timer/timer_page.html @@ -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'); diff --git a/app/utils/config_manager.py b/app/utils/config_manager.py index d5042ee..4c9db3f 100644 --- a/app/utils/config_manager.py +++ b/app/utils/config_manager.py @@ -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 diff --git a/setup.py b/setup.py index b8a45e2..882504a 100644 --- a/setup.py +++ b/setup.py @@ -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=[