mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 07:40:51 -06:00
feat: Enhance UI/UX with improved form validation and error handling
- Add comprehensive form validation system with real-time feedback - Implement enhanced error handling with retry mechanisms and offline support - Update route handlers for improved error responses - Enhance list templates with better error handling and validation - Update dashboard, timer, and report templates with enhanced UI - Improve project service with better error handling - Update config manager utilities - Bump version to 4.2.0 Files updated: - Routes: auth, clients, invoices, projects, quotes, tasks, timer, custom_reports - Templates: base, dashboard, all list views, timer pages, reports - Static: enhanced-ui.js, error-handling-enhanced.js, form-validation.js - Services: project_service.py - Utils: config_manager.py - Version: setup.py
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -356,6 +356,8 @@ class FilterManager {
|
||||
constructor(formElement) {
|
||||
this.form = formElement;
|
||||
this.activeFilters = new Map();
|
||||
this.submitTimeout = null;
|
||||
this.inputTimeouts = new Map();
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -365,8 +367,60 @@ class FilterManager {
|
||||
this.chipsContainer.className = 'filter-chips-container';
|
||||
this.form.parentNode.insertBefore(this.chipsContainer, this.form.nextSibling);
|
||||
|
||||
// Monitor form changes
|
||||
this.form.addEventListener('change', () => this.updateFilters());
|
||||
// Monitor form changes - auto-submit on dropdown changes
|
||||
this.form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
this.updateFilters();
|
||||
// Auto-submit on dropdown changes
|
||||
this.submitForm();
|
||||
});
|
||||
});
|
||||
|
||||
// Monitor text input fields (search fields) - auto-submit with debouncing
|
||||
this.inputTimeouts = new Map();
|
||||
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
// Update filter chips immediately for visual feedback
|
||||
this.updateFilters();
|
||||
|
||||
// Debounce the actual form submission
|
||||
const timeoutKey = input.name || input.id;
|
||||
if (this.inputTimeouts.has(timeoutKey)) {
|
||||
clearTimeout(this.inputTimeouts.get(timeoutKey));
|
||||
}
|
||||
|
||||
// Submit after user stops typing (500ms delay)
|
||||
const timeout = setTimeout(() => {
|
||||
this.submitForm();
|
||||
this.inputTimeouts.delete(timeoutKey);
|
||||
}, 500);
|
||||
|
||||
this.inputTimeouts.set(timeoutKey, timeout);
|
||||
});
|
||||
|
||||
// Also submit on Enter key
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Clear any pending timeout
|
||||
const timeoutKey = input.name || input.id;
|
||||
if (this.inputTimeouts.has(timeoutKey)) {
|
||||
clearTimeout(this.inputTimeouts.get(timeoutKey));
|
||||
this.inputTimeouts.delete(timeoutKey);
|
||||
}
|
||||
// Submit immediately
|
||||
this.submitForm();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to form submit - prevent default and use AJAX instead
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.updateFilters();
|
||||
this.submitForm();
|
||||
});
|
||||
|
||||
// Add quick filters
|
||||
this.addQuickFilters();
|
||||
@@ -471,16 +525,234 @@ class FilterManager {
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = false;
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
// For select elements, set to first option (usually "All" or empty)
|
||||
if (input.options.length > 0) {
|
||||
input.value = input.options[0].value;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
this.form.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
// Update filters and submit
|
||||
this.updateFilters();
|
||||
this.submitForm();
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
// Reset all form fields
|
||||
this.form.reset();
|
||||
this.form.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
|
||||
// For select elements, ensure they're set to their default (first option)
|
||||
this.form.querySelectorAll('select').forEach(select => {
|
||||
if (select.options.length > 0) {
|
||||
select.value = select.options[0].value;
|
||||
}
|
||||
});
|
||||
|
||||
// Explicitly set status to "all" to show all projects
|
||||
const statusSelect = this.form.querySelector('[name="status"]');
|
||||
if (statusSelect) {
|
||||
statusSelect.value = 'all';
|
||||
}
|
||||
|
||||
// Clear all text inputs
|
||||
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
// Update filters and submit
|
||||
this.updateFilters();
|
||||
this.submitForm();
|
||||
}
|
||||
|
||||
submitForm() {
|
||||
// Ensure the form can be submitted (remove any disabled state from submit button)
|
||||
const submitButton = this.form.querySelector('button[type="submit"]');
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.style.display = '';
|
||||
submitButton.style.visibility = '';
|
||||
submitButton.style.opacity = '';
|
||||
}
|
||||
|
||||
// For GET forms (filter forms), use AJAX to avoid page reload
|
||||
if (this.form.method.toUpperCase() === 'GET') {
|
||||
// Use a small delay to prevent rapid-fire submissions
|
||||
if (this.submitTimeout) {
|
||||
clearTimeout(this.submitTimeout);
|
||||
}
|
||||
this.submitTimeout = setTimeout(() => {
|
||||
this.submitViaAjax();
|
||||
}, 100);
|
||||
} else {
|
||||
// For POST forms, dispatch submit event (validation will handle it)
|
||||
this.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
}
|
||||
|
||||
submitViaAjax() {
|
||||
// Build query string from form data
|
||||
const formData = new FormData(this.form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always include status - default to "all" if not set or empty
|
||||
const statusSelect = this.form.querySelector('[name="status"]');
|
||||
let statusValue = statusSelect ? statusSelect.value : 'all';
|
||||
// If status is empty or null, default to "all" to show all projects
|
||||
if (!statusValue || statusValue === '') {
|
||||
statusValue = 'all';
|
||||
}
|
||||
params.append('status', statusValue);
|
||||
|
||||
// Process other form fields
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// Skip status as we already handled it
|
||||
if (key === 'status') {
|
||||
continue;
|
||||
}
|
||||
// Include search if it has a value (trimmed)
|
||||
else if (key === 'search') {
|
||||
const trimmedValue = String(value || '').trim();
|
||||
if (trimmedValue) {
|
||||
params.append(key, trimmedValue);
|
||||
}
|
||||
}
|
||||
// Include other fields if they have values
|
||||
else if (value && String(value).trim() !== '') {
|
||||
params.append(key, String(value).trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Get the form action or current URL
|
||||
const url = this.form.action || window.location.pathname;
|
||||
const queryString = params.toString();
|
||||
// Always include status parameter, even if it's the only one
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : `${url}?status=all`;
|
||||
|
||||
// Debug: log what we're sending
|
||||
console.log('Filter URL:', fullUrl);
|
||||
console.log('Search value:', params.get('search'));
|
||||
console.log('Status value:', params.get('status'));
|
||||
|
||||
// Update URL without page reload
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', fullUrl);
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
const container = document.getElementById('projectsListContainer') || document.getElementById('projectsContainer');
|
||||
if (container) {
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
// Fetch filtered results via AJAX
|
||||
fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
// Debug: log response length
|
||||
console.log('Response HTML length:', html.length);
|
||||
console.log('Response preview:', html.substring(0, 200));
|
||||
// Update the projects list container
|
||||
const projectsContainer = document.getElementById('projectsListContainer');
|
||||
const projectsWrapper = document.getElementById('projectsContainer');
|
||||
|
||||
if (!projectsContainer && projectsWrapper) {
|
||||
// If we don't have the inner container, try to find or create it
|
||||
let innerContainer = projectsWrapper.querySelector('#projectsListContainer');
|
||||
if (!innerContainer) {
|
||||
innerContainer = document.createElement('div');
|
||||
innerContainer.id = 'projectsListContainer';
|
||||
projectsWrapper.insertBefore(innerContainer, projectsWrapper.firstChild);
|
||||
}
|
||||
if (innerContainer) {
|
||||
projectsContainer = innerContainer;
|
||||
}
|
||||
}
|
||||
|
||||
if (projectsContainer) {
|
||||
const trimmedHtml = html.trim();
|
||||
|
||||
// The partial template returns: <div id="projectsListContainer">...</div>
|
||||
// Extract the innerHTML from this div using a simple approach
|
||||
|
||||
// Create a temporary container and parse the HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = trimmedHtml;
|
||||
|
||||
// Find the projectsListContainer in the parsed HTML
|
||||
const responseContainer = tempDiv.querySelector('#projectsListContainer');
|
||||
|
||||
if (responseContainer) {
|
||||
// Use the innerHTML directly
|
||||
projectsContainer.innerHTML = responseContainer.innerHTML;
|
||||
} else {
|
||||
// If the response IS the container (no wrapper), extract content
|
||||
// Try regex to get content between opening and closing div tags
|
||||
const match = trimmedHtml.match(/<div[^>]*id=["']projectsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1] !== undefined) {
|
||||
projectsContainer.innerHTML = match[1];
|
||||
} else {
|
||||
// If all else fails, try to find the first child element
|
||||
const firstChild = tempDiv.firstElementChild;
|
||||
if (firstChild && firstChild.id === 'projectsListContainer') {
|
||||
projectsContainer.innerHTML = firstChild.innerHTML;
|
||||
} else {
|
||||
// Last resort: replace entire container
|
||||
projectsContainer.outerHTML = trimmedHtml;
|
||||
// Re-find the container after replacement
|
||||
const newContainer = document.getElementById('projectsListContainer');
|
||||
if (newContainer) projectsContainer = newContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize any scripts that need to run after content update
|
||||
if (window.setViewMode) {
|
||||
const savedMode = localStorage.getItem('projectsViewMode') || 'list';
|
||||
setViewMode(savedMode);
|
||||
}
|
||||
|
||||
// Update filter chips after content update
|
||||
this.updateFilters();
|
||||
} else {
|
||||
console.error('Could not find projectsListContainer or projectsContainer element');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching filtered projects:', error);
|
||||
// Fallback to regular form submission on error
|
||||
if (container) {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
}
|
||||
// Optionally show an error message
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter projects. Please try again.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// Remove loading indicator
|
||||
if (container) {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,8 +1175,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize filter managers
|
||||
// Initialize filter managers (skip forms that have custom handlers)
|
||||
document.querySelectorAll('form[data-filter-form]').forEach(form => {
|
||||
// Skip forms that have custom AJAX handlers
|
||||
if (form.id === 'projectsFilterForm' ||
|
||||
form.id === 'tasksFilterForm' ||
|
||||
form.id === 'clientsFilterForm' ||
|
||||
form.id === 'invoicesFilterForm' ||
|
||||
form.id === 'quotesFilterForm') {
|
||||
return;
|
||||
}
|
||||
new FilterManager(form);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -192,7 +192,9 @@
|
||||
errorStoppingTimer: '{{ _("Error stopping timer") }}',
|
||||
noFormToSave: '{{ _("No form to save") }}',
|
||||
noTimerFound: '{{ _("No timer found") }}',
|
||||
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}'
|
||||
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}',
|
||||
selectProjectOrClient: '{{ _("Please select either a project or a client") }}',
|
||||
endTimeAfterStartTime: '{{ _("End time must be after start time") }}'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
106
app/templates/clients/_clients_list.html
Normal file
106
app/templates/clients/_clients_list.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<div id="clientsListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllClients()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Contact Person</th>
|
||||
<th class="p-4" data-sortable>Email</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Projects</th>
|
||||
<th class="p-4">Portal</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="client-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ client.id }}" onchange="updateClientsBulkState()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium"><a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">{{ client.name }}</a></td>
|
||||
<td class="p-4">{{ client.contact_person or '—' }}</td>
|
||||
<td class="p-4">
|
||||
{% if client.email %}
|
||||
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if client.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if client.portal_enabled %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Portal: {{ client.portal_username }}">
|
||||
<i class="fas fa-building mr-1"></i>Enabled
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
Disabled
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not clients %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Client
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status != 'active' %}
|
||||
{{ empty_state('fas fa-search', 'No Clients Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new client that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-users', 'No Clients Yet', 'Clients help you organize your projects and manage relationships. Create your first client to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="filterBody">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" data-filter-form>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" id="clientsFilterForm" data-filter-form>
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
@@ -35,118 +35,12 @@
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllClients()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Contact Person</th>
|
||||
<th class="p-4" data-sortable>Email</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Projects</th>
|
||||
<th class="p-4">Portal</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="client-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ client.id }}" onchange="updateClientsBulkState()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium"><a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">{{ client.name }}</a></td>
|
||||
<td class="p-4">{{ client.contact_person or '—' }}</td>
|
||||
<td class="p-4">
|
||||
{% if client.email %}
|
||||
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if client.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if client.portal_enabled %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Portal: {{ client.portal_username }}">
|
||||
<i class="fas fa-building mr-1"></i>Enabled
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
Disabled
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not clients %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Client
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status != 'active' %}
|
||||
{{ empty_state('fas fa-search', 'No Clients Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new client that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-users', 'No Clients Yet', 'Clients help you organize your projects and manage relationships. Create your first client to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="clientsContainer">
|
||||
{% include 'clients/_clients_list.html' %}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
@@ -214,6 +108,181 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
});
|
||||
|
||||
// Clients Filter Handler - AJAX filtering
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let filterTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function getFilterParams() {
|
||||
const form = document.getElementById('clientsFilterForm');
|
||||
if (!form) return {};
|
||||
|
||||
const params = {};
|
||||
|
||||
// Get search input value directly
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other form fields from FormData
|
||||
const formData = new FormData(form);
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'search') continue;
|
||||
const trimmed = String(value || '').trim();
|
||||
if (trimmed && trimmed !== '' && trimmed !== 'all') {
|
||||
params[key] = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// Always include status, default to "all" if not set
|
||||
if (!params.status) {
|
||||
params.status = 'all';
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildFilterUrl() {
|
||||
const params = getFilterParams();
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return `/clients?${queryString}`;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const url = buildFilterUrl();
|
||||
const container = document.getElementById('clientsListContainer');
|
||||
|
||||
if (!container) {
|
||||
console.error('clientsListContainer not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Update URL
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
// Fetch filtered results
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html.trim();
|
||||
|
||||
const newContainer = tempDiv.querySelector('#clientsListContainer');
|
||||
|
||||
if (newContainer) {
|
||||
container.innerHTML = newContainer.innerHTML;
|
||||
} else {
|
||||
const match = html.trim().match(/<div[^>]*id=["']clientsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1]) {
|
||||
container.innerHTML = match[1];
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Filter error:', error);
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter clients. Please refresh the page.', 'error');
|
||||
} else if (window.showToast) {
|
||||
window.showToast('Failed to filter clients. Please refresh the page.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedApplyFilters(delay = 100) {
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
filterTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function debouncedSearch(delay = 500) {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('clientsFilterForm');
|
||||
if (!form) {
|
||||
console.error('Clients filter form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-submit on dropdown changes
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on search input (debounced)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
debouncedSearch(500);
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent form submission (use AJAX instead)
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
function toggleAllClients(){
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
|
||||
157
app/templates/invoices/_invoices_list.html
Normal file
157
app/templates/invoices/_invoices_list.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<div id="invoicesListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('invoices.export_invoices_excel') }}"
|
||||
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
|
||||
title="{{ _('Export to Excel') }}"
|
||||
onclick="showExportLoading(this); return true;">
|
||||
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'invoicesBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="invoicesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllInvoices()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>Number</th>
|
||||
<th class="p-4" data-sortable>Client</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Payment</th>
|
||||
<th class="p-4 table-number" data-sortable>Total</th>
|
||||
<th class="p-4 table-number" data-sortable>Due Date</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="invoice-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ invoice.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium">{{ invoice.invoice_number }}</td>
|
||||
<td class="p-4">{{ invoice.client_name }}</td>
|
||||
<td class="p-4">
|
||||
{% set s = invoice.status %}
|
||||
{% if s == 'draft' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
<i class="fas fa-file-alt mr-1"></i>Draft
|
||||
</span>
|
||||
{% elif s == 'sent' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||
<i class="fas fa-paper-plane mr-1"></i>Sent
|
||||
</span>
|
||||
{% elif s == 'paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
<i class="fas fa-check-circle mr-1"></i>Paid
|
||||
</span>
|
||||
{% elif s == 'overdue' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>Overdue
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ s }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set ps = invoice.payment_status %}
|
||||
{% if ps == 'unpaid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" title="{{ _('Unpaid') }}">
|
||||
<i class="fas fa-times-circle mr-1"></i>Unpaid
|
||||
</span>
|
||||
{% elif ps == 'partially_paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" title="{{ _('Partially Paid') }}">
|
||||
<i class="fas fa-hourglass-half mr-1"></i>Partial
|
||||
</span>
|
||||
{% elif ps == 'fully_paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300" title="{{ _('Fully Paid') }}">
|
||||
<i class="fas fa-check-circle mr-1"></i>Paid
|
||||
</span>
|
||||
{% elif ps == 'overpaid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Overpaid">
|
||||
<i class="fas fa-plus-circle mr-1"></i>Overpaid
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ ps }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 table-number">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-4 table-number">
|
||||
{% if invoice.due_date %}
|
||||
{% set is_overdue = invoice._is_overdue %}
|
||||
<span class="chip whitespace-nowrap {% if is_overdue %}bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border-2 border-red-500{% else %}chip-neutral{% endif %}">
|
||||
{% if is_overdue %}<i class="fas fa-exclamation-circle mr-1"></i>{% endif %}
|
||||
{{ invoice.due_date.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 relative overflow-visible">
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleInvoiceActions(event, {{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
|
||||
Actions <i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
<div id="invoiceActions{{ invoice.id }}" class="invoice-actions-dropdown hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg" style="z-index: 1200;">
|
||||
<div class="py-1">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-eye mr-2"></i>View
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-edit mr-2"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-download mr-2"></i>Download PDF
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<hr class="my-1 border-border-light dark:border-border-dark">
|
||||
<button type="button" onclick="showDeleteModal({{ invoice.id }}, '{{ invoice.invoice_number }}')" class="w-full text-left block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-trash mr-2"></i>Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not invoices %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Invoice
|
||||
</a>
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if request.args.get('search') or request.args.get('status') or request.args.get('client') %}
|
||||
{{ empty_state('fas fa-search', 'No Invoices Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new invoice that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-file-invoice', 'No Invoices Yet', 'Create professional invoices to bill your clients for completed work. Create your first invoice to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="filterBody">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4" data-filter-form>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4" id="invoicesFilterForm" data-filter-form>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -71,169 +71,12 @@
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ request.args.get('search', '') }}" class="form-input" placeholder="{{ _('Invoice number or client') }}">
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ invoices|length }} invoice{{ 's' if invoices|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('invoices.export_invoices_excel') }}"
|
||||
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
|
||||
title="{{ _('Export to Excel') }}"
|
||||
onclick="showExportLoading(this); return true;">
|
||||
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'invoicesBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="invoicesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllInvoices()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>Number</th>
|
||||
<th class="p-4" data-sortable>Client</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Payment</th>
|
||||
<th class="p-4 table-number" data-sortable>Total</th>
|
||||
<th class="p-4 table-number" data-sortable>Due Date</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="invoice-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ invoice.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium">{{ invoice.invoice_number }}</td>
|
||||
<td class="p-4">{{ invoice.client_name }}</td>
|
||||
<td class="p-4">
|
||||
{% set s = invoice.status %}
|
||||
{% if s == 'draft' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
<i class="fas fa-file-alt mr-1"></i>Draft
|
||||
</span>
|
||||
{% elif s == 'sent' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||
<i class="fas fa-paper-plane mr-1"></i>Sent
|
||||
</span>
|
||||
{% elif s == 'paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
<i class="fas fa-check-circle mr-1"></i>Paid
|
||||
</span>
|
||||
{% elif s == 'overdue' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>Overdue
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ s }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set ps = invoice.payment_status %}
|
||||
{% if ps == 'unpaid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" title="{{ _('Unpaid') }}">
|
||||
<i class="fas fa-times-circle mr-1"></i>Unpaid
|
||||
</span>
|
||||
{% elif ps == 'partially_paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" title="{{ _('Partially Paid') }}">
|
||||
<i class="fas fa-hourglass-half mr-1"></i>Partial
|
||||
</span>
|
||||
{% elif ps == 'fully_paid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300" title="{{ _('Fully Paid') }}">
|
||||
<i class="fas fa-check-circle mr-1"></i>Paid
|
||||
</span>
|
||||
{% elif ps == 'overpaid' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Overpaid">
|
||||
<i class="fas fa-plus-circle mr-1"></i>Overpaid
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ ps }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 table-number">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-4 table-number">
|
||||
{% if invoice.due_date %}
|
||||
{% set is_overdue = invoice._is_overdue %}
|
||||
<span class="chip whitespace-nowrap {% if is_overdue %}bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border-2 border-red-500{% else %}chip-neutral{% endif %}">
|
||||
{% if is_overdue %}<i class="fas fa-exclamation-circle mr-1"></i>{% endif %}
|
||||
{{ invoice.due_date.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 relative overflow-visible">
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleInvoiceActions(event, {{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
|
||||
Actions <i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
<div id="invoiceActions{{ invoice.id }}" class="invoice-actions-dropdown hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg" style="z-index: 1200;">
|
||||
<div class="py-1">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-eye mr-2"></i>View
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-edit mr-2"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-download mr-2"></i>Download PDF
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<hr class="my-1 border-border-light dark:border-border-dark">
|
||||
<button type="button" onclick="showDeleteModal({{ invoice.id }}, '{{ invoice.invoice_number }}')" class="w-full text-left block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-trash mr-2"></i>Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not invoices %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Invoice
|
||||
</a>
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if request.args.get('search') or request.args.get('status') or request.args.get('client') %}
|
||||
{{ empty_state('fas fa-search', 'No Invoices Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new invoice that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-file-invoice', 'No Invoices Yet', 'Create professional invoices to bill your clients for completed work. Create your first invoice to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="invoicesContainer">
|
||||
{% include 'invoices/_invoices_list.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Forms (hidden) -->
|
||||
@@ -356,10 +199,9 @@ function filterByStatus(status) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
// Submit form
|
||||
const form = document.querySelector('[data-filter-form]');
|
||||
if (form) {
|
||||
form.submit();
|
||||
// Trigger AJAX filter
|
||||
if (window.applyInvoiceFilters) {
|
||||
window.applyInvoiceFilters();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +331,181 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
});
|
||||
|
||||
// Invoices Filter Handler - AJAX filtering
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let filterTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function getFilterParams() {
|
||||
const form = document.getElementById('invoicesFilterForm');
|
||||
if (!form) return {};
|
||||
|
||||
const params = {};
|
||||
|
||||
// Get search input value directly
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get status from hidden input
|
||||
const statusInput = form.querySelector('#statusFilter');
|
||||
if (statusInput && statusInput.value) {
|
||||
params.status = statusInput.value;
|
||||
}
|
||||
|
||||
// Get payment status
|
||||
const paymentStatusSelect = form.querySelector('[name="payment_status"]');
|
||||
if (paymentStatusSelect && paymentStatusSelect.value) {
|
||||
params.payment_status = paymentStatusSelect.value;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildFilterUrl() {
|
||||
const params = getFilterParams();
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return `/invoices?${queryString}`;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const url = buildFilterUrl();
|
||||
const container = document.getElementById('invoicesListContainer');
|
||||
|
||||
if (!container) {
|
||||
console.error('invoicesListContainer not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Update URL
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
// Fetch filtered results
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html.trim();
|
||||
|
||||
const newContainer = tempDiv.querySelector('#invoicesListContainer');
|
||||
|
||||
if (newContainer) {
|
||||
container.innerHTML = newContainer.innerHTML;
|
||||
} else {
|
||||
const match = html.trim().match(/<div[^>]*id=["']invoicesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1]) {
|
||||
container.innerHTML = match[1];
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Filter error:', error);
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter invoices. Please refresh the page.', 'error');
|
||||
} else if (window.showToast) {
|
||||
window.showToast('Failed to filter invoices. Please refresh the page.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Export function for status filter buttons
|
||||
window.applyInvoiceFilters = applyFilters;
|
||||
|
||||
function debouncedApplyFilters(delay = 100) {
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
filterTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function debouncedSearch(delay = 500) {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('invoicesFilterForm');
|
||||
if (!form) {
|
||||
console.error('Invoices filter form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-submit on dropdown changes
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on search input (debounced)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
debouncedSearch(500);
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent form submission (use AJAX instead)
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
// Bulk actions for invoices
|
||||
function toggleAllInvoices() {
|
||||
|
||||
@@ -153,60 +153,62 @@
|
||||
<!-- Right Column: Real Insights -->
|
||||
<div class="space-y-6">
|
||||
<!-- Weekly Goal Widget -->
|
||||
{% if current_week_goal %}
|
||||
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
<i class="fas fa-bullseye mr-2"></i>
|
||||
{{ _('Weekly Goal') }}
|
||||
</h2>
|
||||
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
{% if settings and settings.ui_allow_weekly_goals and current_user.ui_show_weekly_goals %}
|
||||
{% if current_week_goal %}
|
||||
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
<i class="fas fa-bullseye mr-2"></i>
|
||||
{{ _('Weekly Goal') }}
|
||||
</h2>
|
||||
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2 opacity-90">
|
||||
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
|
||||
<span>{{ current_week_goal.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
|
||||
<div class="bg-white rounded-full h-3 transition-all duration-300"
|
||||
style="width: {{ current_week_goal.progress_percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
|
||||
</div>
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
|
||||
<div class="mt-3 text-sm opacity-90">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2 opacity-90">
|
||||
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
|
||||
<span>{{ current_week_goal.progress_percentage }}%</span>
|
||||
{% else %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('No Weekly Goal') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('Set a weekly time goal to track your progress') }}
|
||||
</p>
|
||||
<a href="{{ url_for('weekly_goals.create') }}"
|
||||
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
|
||||
<div class="bg-white rounded-full h-3 transition-all duration-300"
|
||||
style="width: {{ current_week_goal.progress_percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
|
||||
</div>
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
|
||||
<div class="mt-3 text-sm opacity-90">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('No Weekly Goal') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('Set a weekly time goal to track your progress') }}
|
||||
</p>
|
||||
<a href="{{ url_for('weekly_goals.create') }}"
|
||||
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-md dashboard-widget animated-card">
|
||||
@@ -454,6 +456,44 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation: ensure either project or client is selected
|
||||
const startTimerForm = modal ? modal.querySelector('form') : null;
|
||||
if (startTimerForm) {
|
||||
// Store original button state to restore if needed
|
||||
const submitBtn = startTimerForm.querySelector('button[type="submit"]');
|
||||
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
|
||||
|
||||
startTimerForm.addEventListener('submit', function(e) {
|
||||
// Validate project or client selection
|
||||
const projectVal = projectSelect ? projectSelect.value : '';
|
||||
const clientVal = clientSelect ? clientSelect.value : '';
|
||||
if (!projectVal && !clientVal) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // Stop other handlers from running
|
||||
|
||||
// Ensure button state is preserved
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Show error message using toast notification
|
||||
const errorMsg = '{{ _("Please select either a project or a client") }}';
|
||||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||||
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
// After showing error, ensure button is still in correct state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, true); // Use capture phase to run before other handlers
|
||||
}
|
||||
}
|
||||
|
||||
// Template application function
|
||||
|
||||
295
app/templates/projects/_projects_list.html
Normal file
295
app/templates/projects/_projects_list.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<div id="projectsListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center gap-1 bg-background-light dark:bg-background-dark rounded-lg p-1">
|
||||
<button type="button" id="listViewBtn" onclick="setViewMode('list')" class="px-3 py-1.5 text-sm rounded transition-colors view-mode-btn active" data-view="list" title="{{ _('List View') }}">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
<button type="button" id="gridViewBtn" onclick="setViewMode('grid')" class="px-3 py-1.5 text-sm rounded transition-colors view-mode-btn" data-view="grid" title="{{ _('Grid View') }}">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a href="{{ url_for('projects.export_projects', status=request.args.get('status', 'active'), client=request.args.get('client', ''), search=request.args.get('search', ''), favorites=request.args.get('favorites', '')) }}"
|
||||
class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center export-btn"
|
||||
title="{{ _('Export to CSV') }}"
|
||||
onclick="showExportLoading(this); return true;">
|
||||
<i class="fas fa-download mr-1"></i> <span class="export-text">Export</span>
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'projectsBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="projectsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkArchiveDialog()"><i class="fas fa-archive mr-2 text-gray-600"></i>Archive</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div id="listView" class="view-container">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllProjects()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4 w-10"></th>
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Client</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Billable</th>
|
||||
<th class="p-4" data-sortable>Rate</th>
|
||||
<th class="p-4" data-sortable>Budget</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="project-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ project.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 text-center">
|
||||
{% set is_fav = favorite_project_ids and project.id in favorite_project_ids %}
|
||||
<button type="button"
|
||||
class="favorite-btn text-xl hover:scale-110 transition-transform"
|
||||
data-project-id="{{ project.id }}"
|
||||
data-is-favorited="{{ 'true' if is_fav else 'false' }}"
|
||||
onclick="toggleFavorite({{ project.id }}, this)"
|
||||
title="{{ 'Remove from favorites' if is_fav else 'Add to favorites' }}">
|
||||
<i class="{{ 'fas fa-star text-yellow-500' if is_fav else 'far fa-star text-gray-400' }}"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td class="p-4 font-medium"><a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">{{ project.name }}</a></td>
|
||||
<td class="p-4">
|
||||
{% if project.client_id %}
|
||||
<a href="{{ url_for('clients.view_client', client_id=project.client_id) }}" class="text-primary hover:underline">{{ project.client }}</a>
|
||||
{% else %}
|
||||
{{ project.client }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% elif project.status == 'inactive' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Inactive</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Archived</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.billable %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Billable</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">Non-billable</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.hourly_rate %}
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ '%.2f'|format(project.hourly_rate|float) }}/h</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.budget_amount %}
|
||||
{% set consumed = (project.budget_consumed_amount or 0.0) %}
|
||||
{% set total = project.budget_amount|float %}
|
||||
{% set pct = (consumed / total * 100) if total > 0 else 0 %}
|
||||
{% if pct >= 90 %}
|
||||
{% set badge_classes = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' %}
|
||||
{% elif pct >= 70 %}
|
||||
{% set badge_classes = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' %}
|
||||
{% else %}
|
||||
{% set badge_classes = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' %}
|
||||
{% endif %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ badge_classes }}">{{ pct|round(0) }}%</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div id="gridView" class="view-container hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for project in projects %}
|
||||
<div class="project-card bg-card-light dark:bg-card-dark rounded-lg shadow-md hover:shadow-lg transition-all duration-200 relative overflow-hidden group">
|
||||
<!-- Quick Actions on Hover -->
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
|
||||
class="p-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
title="{{ _('View Project') }}">
|
||||
<i class="fas fa-eye text-xs"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or has_permission('edit_projects') %}
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
|
||||
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
title="{{ _('Edit Project') }}">
|
||||
<i class="fas fa-edit text-xs"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button"
|
||||
class="favorite-btn p-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors"
|
||||
data-project-id="{{ project.id }}"
|
||||
data-is-favorited="{{ 'true' if (favorite_project_ids and project.id in favorite_project_ids) else 'false' }}"
|
||||
onclick="toggleFavorite({{ project.id }}, this)"
|
||||
title="{{ 'Remove from favorites' if (favorite_project_ids and project.id in favorite_project_ids) else 'Add to favorites' }}">
|
||||
<i class="{{ 'fas' if (favorite_project_ids and project.id in favorite_project_ids) else 'far' }} fa-star text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Header -->
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark flex-1 pr-2">
|
||||
{{ project.name }}
|
||||
</h3>
|
||||
<!-- Status Badge -->
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap
|
||||
{% if project.status == 'active' %}
|
||||
bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif project.status == 'inactive' %}
|
||||
bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300
|
||||
{% else %}
|
||||
bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200
|
||||
{% endif %}">
|
||||
{{ project.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if project.client %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
|
||||
<i class="fas fa-building mr-1"></i>{{ project.client }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if project.description %}
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4 line-clamp-2 prose prose-sm dark:prose-invert max-w-none">
|
||||
{{ project.description | markdown | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicators -->
|
||||
<div class="px-6 pb-4 space-y-3">
|
||||
{% if project.budget_amount %}
|
||||
{% set consumed = (project.budget_consumed_amount or 0.0) %}
|
||||
{% set total = project.budget_amount|float %}
|
||||
{% set pct = (consumed / total * 100) if total > 0 else 0 %}
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark">Budget</span>
|
||||
<span class="text-xs font-semibold
|
||||
{% if pct >= 90 %}text-red-600
|
||||
{% elif pct >= 70 %}text-amber-600
|
||||
{% else %}text-green-600{% endif %}">
|
||||
{{ pct|round(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300
|
||||
{% if pct >= 90 %}bg-red-500
|
||||
{% elif pct >= 70 %}bg-amber-500
|
||||
{% else %}bg-green-500{% endif %}"
|
||||
style="width: {{ [pct,100]|min }}%">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ "%.2f"|format(consumed) }} / {{ "%.2f"|format(total) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hours Progress -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark">Hours</span>
|
||||
<span class="text-xs font-semibold text-text-light dark:text-text-dark">
|
||||
{{ "%.1f"|format(project.total_hours) }}h
|
||||
</span>
|
||||
</div>
|
||||
{% if project.estimated_hours %}
|
||||
{% set hours_pct = (project.total_hours / project.estimated_hours * 100) if project.estimated_hours > 0 else 0 %}
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style="width: {{ [hours_pct,100]|min }}%">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ "%.1f"|format(project.total_hours) }} / {{ "%.1f"|format(project.estimated_hours) }}h
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Footer -->
|
||||
<div class="px-6 py-4 border-t border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
{% if project.billable %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
<i class="fas fa-dollar-sign mr-1"></i>Billable
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if project.hourly_rate %}
|
||||
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ '%.2f'|format(project.hourly_rate|float) }}/h
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
|
||||
class="text-primary hover:text-primary/80 text-sm font-medium">
|
||||
View <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not projects %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('projects.create_project') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Project
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if request.args.get('search') or request.args.get('client') or request.args.get('status') != 'all' %}
|
||||
{{ empty_state('fas fa-search', 'No Projects Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new project that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-folder-open', 'No Projects Yet', 'Projects help you organize your work and track time efficiently. Create your first project to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="filterBody">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4" data-filter-form>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4" id="projectsFilterForm" data-filter-form>
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
@@ -30,7 +30,7 @@
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All</option>
|
||||
<option value="all" {% if not status or status == 'all' %}selected{% endif %}>All</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
<option value="archived" {% if status == 'archived' %}selected{% endif %}>Archived</option>
|
||||
@@ -52,14 +52,12 @@
|
||||
<option value="true" {% if favorites_only %}selected{% endif %}>⭐ Favorites Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="projectsContainer">
|
||||
<div id="projectsListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
|
||||
@@ -354,6 +352,7 @@
|
||||
{{ empty_state('fas fa-folder-open', 'No Projects Yet', 'Projects help you organize your work and track time efficiently. Create your first project to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -735,5 +734,313 @@ function toggleFavorite(projectId, button) {
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Projects Filter Handler - Simple AJAX filtering
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let filterTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function getFilterParams() {
|
||||
const form = document.getElementById('projectsFilterForm');
|
||||
if (!form) return {};
|
||||
|
||||
const params = {};
|
||||
|
||||
// Get search input value directly (more reliable than FormData for text inputs)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other form fields from FormData
|
||||
const formData = new FormData(form);
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// Skip search as we already handled it above
|
||||
if (key === 'search') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = String(value || '').trim();
|
||||
if (trimmed && trimmed !== '') {
|
||||
params[key] = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// Always include status, default to "all" if not set
|
||||
if (!params.status) {
|
||||
params.status = 'all';
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildFilterUrl() {
|
||||
const params = getFilterParams();
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return `/projects?${queryString}`;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const url = buildFilterUrl();
|
||||
const container = document.getElementById('projectsListContainer');
|
||||
|
||||
if (!container) {
|
||||
console.error('projectsListContainer not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Update URL
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
// Fetch filtered results
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
// The response should be the projectsListContainer div
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html.trim();
|
||||
|
||||
const newContainer = tempDiv.querySelector('#projectsListContainer');
|
||||
|
||||
if (newContainer) {
|
||||
container.innerHTML = newContainer.innerHTML;
|
||||
} else {
|
||||
// Fallback: try to extract content using regex
|
||||
const match = html.trim().match(/<div[^>]*id=["']projectsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1]) {
|
||||
container.innerHTML = match[1];
|
||||
} else {
|
||||
// Last resort: use the HTML as-is
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize view mode
|
||||
if (window.setViewMode) {
|
||||
const savedMode = localStorage.getItem('projectsViewMode') || 'list';
|
||||
setViewMode(savedMode);
|
||||
}
|
||||
|
||||
// Update filter chips after successful filter
|
||||
updateFilterChips();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Filter error:', error);
|
||||
// Restore container state
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
|
||||
// Show error message
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter projects. Please refresh the page.', 'error');
|
||||
} else if (window.showToast) {
|
||||
window.showToast('Failed to filter projects. Please refresh the page.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedApplyFilters(delay = 100) {
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
filterTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function debouncedSearch(delay = 500) {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function updateFilterChips() {
|
||||
const form = document.getElementById('projectsFilterForm');
|
||||
if (!form) return;
|
||||
|
||||
const params = getFilterParams();
|
||||
const chipsContainer = document.getElementById('filterChipsContainer');
|
||||
|
||||
// Remove old chips container if it exists
|
||||
if (chipsContainer) {
|
||||
chipsContainer.remove();
|
||||
}
|
||||
|
||||
// Get active filters (exclude empty values and "all" status)
|
||||
const activeFilters = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value && value !== 'all' && value !== '' && value !== 'false') {
|
||||
activeFilters[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// If no active filters, don't show chips
|
||||
if (Object.keys(activeFilters).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create chips container
|
||||
const container = document.createElement('div');
|
||||
container.id = 'filterChipsContainer';
|
||||
container.className = 'flex flex-wrap items-center gap-2 mb-4';
|
||||
|
||||
// Add filter chips
|
||||
Object.entries(activeFilters).forEach(([key, value]) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary/10 dark:bg-primary/20 text-primary border border-primary/20 dark:border-primary/30';
|
||||
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
const label = input?.labels?.[0]?.textContent || key;
|
||||
|
||||
chip.innerHTML = `
|
||||
<span class="font-medium">${label}:</span>
|
||||
<span class="ml-1">${value}</span>
|
||||
<button type="button" class="ml-2 hover:text-red-600 transition-colors" data-filter-key="${key}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add remove listener
|
||||
chip.querySelector('button').addEventListener('click', () => {
|
||||
removeFilter(key);
|
||||
});
|
||||
|
||||
container.appendChild(chip);
|
||||
});
|
||||
|
||||
// Add "Clear All" button
|
||||
const clearAllBtn = document.createElement('button');
|
||||
clearAllBtn.type = 'button';
|
||||
clearAllBtn.className = 'text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors';
|
||||
clearAllBtn.innerHTML = '<i class="fas fa-times-circle mr-1"></i> Clear all';
|
||||
clearAllBtn.addEventListener('click', clearAllFilters);
|
||||
container.appendChild(clearAllBtn);
|
||||
|
||||
// Insert after filter form
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
if (filterBody) {
|
||||
filterBody.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFilter(key) {
|
||||
const form = document.getElementById('projectsFilterForm');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
if (input.tagName === 'SELECT') {
|
||||
input.value = input.options[0].value; // First option (usually "All" or empty)
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
const form = document.getElementById('projectsFilterForm');
|
||||
if (!form) return;
|
||||
|
||||
// Reset all form fields
|
||||
form.reset();
|
||||
|
||||
// Set status to "all"
|
||||
const statusSelect = form.querySelector('[name="status"]');
|
||||
if (statusSelect) {
|
||||
statusSelect.value = 'all';
|
||||
}
|
||||
|
||||
// Clear text inputs
|
||||
form.querySelectorAll('input[type="text"]').forEach(input => {
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
// Apply filters immediately
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('projectsFilterForm');
|
||||
if (!form) {
|
||||
console.error('Projects filter form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-submit on dropdown changes
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on search input (debounced)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
debouncedSearch(500);
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Search input not found in form');
|
||||
}
|
||||
|
||||
// Prevent form submission (use AJAX instead)
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Initial filter chips update
|
||||
setTimeout(updateFilterChips, 100);
|
||||
|
||||
// Export clear function
|
||||
window.clearProjectsFilters = clearAllFilters;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
101
app/templates/quotes/_quotes_list.html
Normal file
101
app/templates/quotes/_quotes_list.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<div id="quotesListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found
|
||||
</h3>
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'quotesBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="quotesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('duplicate')"><i class="fas fa-copy mr-2 text-blue-600"></i>{{ _('Duplicate') }}</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('mark_sent')"><i class="fas fa-paper-plane mr-2 text-green-600"></i>{{ _('Mark as Sent') }}</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('delete')"><i class="fas fa-trash mr-2"></i>{{ _('Delete') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if quotes %}
|
||||
<form method="POST" action="{{ url_for('quotes.bulk_action') }}" id="bulk-action-form" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" id="bulkActionValue" value="">
|
||||
</form>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllQuotes()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>{{ _('Quote Number') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Title') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Client') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Amount') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Status') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Created') }}</th>
|
||||
<th class="p-4">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quote in quotes %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="quote-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ quote.id }}" onchange="updateQuotesBulkState()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.quote_number }}</a></td>
|
||||
<td class="p-4"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.title }}</a></td>
|
||||
<td class="p-4">{{ quote.client.name }}</td>
|
||||
<td class="p-4">
|
||||
{% if quote.total_amount %}
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if quote.status == 'draft' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ _('Draft') }}</span>
|
||||
{% elif quote.status == 'sent' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ _('Sent') }}</span>
|
||||
{% elif quote.status == 'accepted' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ _('Accepted') }}</span>
|
||||
{% elif quote.status == 'rejected' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Rejected') }}</span>
|
||||
{% elif quote.status == 'expired' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{{ _('Expired') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ _('View') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<a href="{{ url_for('quotes.create_quote') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Create Your First Quote') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>{{ _('Learn More') }}
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status != 'all' %}
|
||||
{{ empty_state('fas fa-search', 'No Quotes Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new quote that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-file-contract', 'No Quotes Yet', 'Quotes help you manage client proposals and track acceptance rates. Create your first quote to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="filterBody">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" data-filter-form>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" id="quotesFilterForm" data-filter-form>
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Search') }}</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input" placeholder="{{ _('Search quotes...') }}">
|
||||
@@ -85,112 +85,12 @@
|
||||
<option value="expired" {% if status == 'expired' %}selected{% endif %}>{{ _('Expired') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">{{ _('Filter') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ quotes|length }} quote{{ 's' if quotes|length != 1 else '' }} found
|
||||
</h3>
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'quotesBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="quotesBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('duplicate')"><i class="fas fa-copy mr-2 text-blue-600"></i>{{ _('Duplicate') }}</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('mark_sent')"><i class="fas fa-paper-plane mr-2 text-green-600"></i>{{ _('Mark as Sent') }}</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAction('delete')"><i class="fas fa-trash mr-2"></i>{{ _('Delete') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if quotes %}
|
||||
<form method="POST" action="{{ url_for('quotes.bulk_action') }}" id="bulk-action-form" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" id="bulkActionValue" value="">
|
||||
</form>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllQuotes()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>{{ _('Quote Number') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Title') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Client') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Amount') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Status') }}</th>
|
||||
<th class="p-4" data-sortable>{{ _('Created') }}</th>
|
||||
<th class="p-4">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quote in quotes %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="quote-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ quote.id }}" onchange="updateQuotesBulkState()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4 font-medium"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.quote_number }}</a></td>
|
||||
<td class="p-4"><a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ quote.title }}</a></td>
|
||||
<td class="p-4">{{ quote.client.name }}</td>
|
||||
<td class="p-4">
|
||||
{% if quote.total_amount %}
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ "%.2f"|format(quote.total_amount) }} {{ quote.currency_code }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if quote.status == 'draft' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ _('Draft') }}</span>
|
||||
{% elif quote.status == 'sent' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ _('Sent') }}</span>
|
||||
{% elif quote.status == 'accepted' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ _('Accepted') }}</span>
|
||||
{% elif quote.status == 'rejected' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Rejected') }}</span>
|
||||
{% elif quote.status == 'expired' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{{ _('Expired') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ quote.created_at.strftime('%Y-%m-%d') if quote.created_at else '' }}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('quotes.view_quote', quote_id=quote.id) }}" class="text-primary hover:underline">{{ _('View') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% set actions %}
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
<a href="{{ url_for('quotes.create_quote') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Create Your First Quote') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>{{ _('Learn More') }}
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status != 'all' %}
|
||||
{{ empty_state('fas fa-search', 'No Quotes Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new quote that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-file-contract', 'No Quotes Yet', 'Quotes help you manage client proposals and track acceptance rates. Create your first quote to get started!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="quotesContainer">
|
||||
{% include 'quotes/_quotes_list.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -245,6 +145,179 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
});
|
||||
|
||||
// Quotes Filter Handler - AJAX filtering
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let filterTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function getFilterParams() {
|
||||
const form = document.getElementById('quotesFilterForm');
|
||||
if (!form) return {};
|
||||
|
||||
const params = {};
|
||||
|
||||
// Get search input value directly
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get status
|
||||
const statusSelect = form.querySelector('[name="status"]');
|
||||
if (statusSelect) {
|
||||
const statusValue = statusSelect.value;
|
||||
if (statusValue && statusValue !== 'all') {
|
||||
params.status = statusValue;
|
||||
} else {
|
||||
params.status = 'all';
|
||||
}
|
||||
} else {
|
||||
params.status = 'all';
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildFilterUrl() {
|
||||
const params = getFilterParams();
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return `/quotes?${queryString}`;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const url = buildFilterUrl();
|
||||
const container = document.getElementById('quotesListContainer');
|
||||
|
||||
if (!container) {
|
||||
console.error('quotesListContainer not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Update URL
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
// Fetch filtered results
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html.trim();
|
||||
|
||||
const newContainer = tempDiv.querySelector('#quotesListContainer');
|
||||
|
||||
if (newContainer) {
|
||||
container.innerHTML = newContainer.innerHTML;
|
||||
} else {
|
||||
const match = html.trim().match(/<div[^>]*id=["']quotesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1]) {
|
||||
container.innerHTML = match[1];
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Filter error:', error);
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter quotes. Please refresh the page.', 'error');
|
||||
} else if (window.showToast) {
|
||||
window.showToast('Failed to filter quotes. Please refresh the page.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedApplyFilters(delay = 100) {
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
filterTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function debouncedSearch(delay = 500) {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('quotesFilterForm');
|
||||
if (!form) {
|
||||
console.error('Quotes filter form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-submit on dropdown changes
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on search input (debounced)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
debouncedSearch(500);
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent form submission (use AJAX instead)
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
{% if current_user.is_admin or has_permission('create_quotes') %}
|
||||
function toggleAllQuotes(){
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
app/templates/tasks/_tasks_list.html
Normal file
103
app/templates/tasks/_tasks_list.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<div id="tasksListContainer">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</a>
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60 btn-press transition-smooth" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllTasks()">
|
||||
</th>
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Project</th>
|
||||
<th class="p-4" data-sortable>Priority</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4 table-number" data-sortable>Due</th>
|
||||
<th class="p-4 table-number" data-sortable>Progress</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-context-menu='[{"label": "View", "icon": "fas fa-eye", "action": {"type": "view", "url": "{{ url_for('tasks.view_task', task_id=task.id) }}"}}, {"label": "Edit", "icon": "fas fa-edit", "action": {"type": "edit", "url": "{{ url_for('tasks.edit_task', task_id=task.id) }}"}}, {"separator": true}, {"label": "Delete", "icon": "fas fa-trash", "danger": true, "action": {"type": "delete", "url": "{{ url_for('tasks.delete_task', task_id=task.id) }}", "confirm": "Are you sure you want to delete this task?"}}]'>
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="task-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary transition-all" value="{{ task.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<td class="p-4"><a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ task.name }}</a></td>
|
||||
<td class="p-4"><a href="{{ url_for('projects.view_project', project_id=task.project_id) }}" class="text-primary hover:underline">{{ task.project.name }}</a></td>
|
||||
<td class="p-4">
|
||||
{% set p = task.priority %}
|
||||
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ pcls }}">{{ task.priority_display }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set s = task.status %}
|
||||
{% set statusColors = {'todo': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'in_progress': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
'review': 'status-pending',
|
||||
'done': 'status-active',
|
||||
'cancelled': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'} %}
|
||||
{% set scls = statusColors.get(s, 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ scls }}">{{ task.status_display }}</span>
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% if task.due_date %}
|
||||
{% set overdue = task.is_overdue %}
|
||||
<span class="chip whitespace-nowrap {{ 'status-overdue' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% set pct = task.progress_percentage or 0 %}
|
||||
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not tasks %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors btn-press shadow-md">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Task
|
||||
</a>
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status or priority or project_id or assigned_to %}
|
||||
{{ empty_state('fas fa-search', 'No Tasks Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new task that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Tasks help you break down projects into manageable pieces. Create your first task to get started organizing your work!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="filterBody">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-filter-form>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="tasksFilterForm" data-filter-form>
|
||||
<div class="lg:col-span-1">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
@@ -83,117 +83,12 @@
|
||||
<input type="checkbox" name="overdue" id="overdue" value="1" {% if overdue %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="overdue" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Overdue only</label>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</a>
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60 btn-press transition-smooth" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4 w-12">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllTasks()">
|
||||
</th>
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Project</th>
|
||||
<th class="p-4" data-sortable>Priority</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4 table-number" data-sortable>Due</th>
|
||||
<th class="p-4 table-number" data-sortable>Progress</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-context-menu='[{"label": "View", "icon": "fas fa-eye", "action": {"type": "view", "url": "{{ url_for('tasks.view_task', task_id=task.id) }}"}}, {"label": "Edit", "icon": "fas fa-edit", "action": {"type": "edit", "url": "{{ url_for('tasks.edit_task', task_id=task.id) }}"}}, {"separator": true}, {"label": "Delete", "icon": "fas fa-trash", "danger": true, "action": {"type": "delete", "url": "{{ url_for('tasks.delete_task', task_id=task.id) }}", "confirm": "Are you sure you want to delete this task?"}}]'>
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="task-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary transition-all" value="{{ task.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<td class="p-4"><a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ task.name }}</a></td>
|
||||
<td class="p-4"><a href="{{ url_for('projects.view_project', project_id=task.project_id) }}" class="text-primary hover:underline">{{ task.project.name }}</a></td>
|
||||
<td class="p-4">
|
||||
{% set p = task.priority %}
|
||||
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ pcls }}">{{ task.priority_display }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set s = task.status %}
|
||||
{% set statusColors = {'todo': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'in_progress': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
'review': 'status-pending',
|
||||
'done': 'status-active',
|
||||
'cancelled': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'} %}
|
||||
{% set scls = statusColors.get(s, 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ scls }}">{{ task.status_display }}</span>
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% if task.due_date %}
|
||||
{% set overdue = task.is_overdue %}
|
||||
<span class="chip whitespace-nowrap {{ 'status-overdue' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% set pct = task.progress_percentage or 0 %}
|
||||
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not tasks %}
|
||||
{% from "components/ui.html" import empty_state %}
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors btn-press shadow-md">
|
||||
<i class="fas fa-plus mr-2"></i>Create Your First Task
|
||||
</a>
|
||||
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="fas fa-question-circle mr-2"></i>Learn More
|
||||
</a>
|
||||
{% endset %}
|
||||
{% if search or status or priority or project_id or assigned_to %}
|
||||
{{ empty_state('fas fa-search', 'No Tasks Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new task that matches your criteria.', actions, type='no-results') }}
|
||||
{% else %}
|
||||
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Tasks help you break down projects into manageable pieces. Create your first task to get started organizing your work!', actions, type='no-data') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible" id="tasksContainer">
|
||||
{% include 'tasks/_tasks_list.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Forms (hidden) -->
|
||||
@@ -573,5 +468,182 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
});
|
||||
|
||||
// Tasks Filter Handler - AJAX filtering
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let filterTimeout = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
function getFilterParams() {
|
||||
const form = document.getElementById('tasksFilterForm');
|
||||
if (!form) return {};
|
||||
|
||||
const params = {};
|
||||
|
||||
// Get search input value directly
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
params.search = searchValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other form fields from FormData
|
||||
const formData = new FormData(form);
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'search') continue;
|
||||
const trimmed = String(value || '').trim();
|
||||
if (trimmed && trimmed !== '') {
|
||||
params[key] = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildFilterUrl() {
|
||||
const params = getFilterParams();
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return `/tasks?${queryString}`;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const url = buildFilterUrl();
|
||||
const container = document.getElementById('tasksListContainer');
|
||||
|
||||
if (!container) {
|
||||
console.error('tasksListContainer not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
container.style.opacity = '0.5';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Update URL
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
// Fetch filtered results
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html.trim();
|
||||
|
||||
const newContainer = tempDiv.querySelector('#tasksListContainer');
|
||||
|
||||
if (newContainer) {
|
||||
container.innerHTML = newContainer.innerHTML;
|
||||
} else {
|
||||
const match = html.trim().match(/<div[^>]*id=["']tasksListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
||||
if (match && match[1]) {
|
||||
container.innerHTML = match[1];
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Filter error:', error);
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show('Failed to filter tasks. Please refresh the page.', 'error');
|
||||
} else if (window.showToast) {
|
||||
window.showToast('Failed to filter tasks. Please refresh the page.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
container.style.opacity = '';
|
||||
container.style.pointerEvents = '';
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedApplyFilters(delay = 100) {
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
filterTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
function debouncedSearch(delay = 500) {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = setTimeout(applyFilters, delay);
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('tasksFilterForm');
|
||||
if (!form) {
|
||||
console.error('Tasks filter form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-submit on dropdown changes
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on checkbox changes
|
||||
form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
debouncedApplyFilters(100);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit on search input (debounced)
|
||||
const searchInput = form.querySelector('input[name="search"], input#search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
debouncedSearch(500);
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent form submission (use AJAX instead)
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
if (filterTimeout) {
|
||||
clearTimeout(filterTimeout);
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -74,22 +74,22 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Date') }}</label>
|
||||
<input type="date" name="start_date" id="start_date" required class="form-input">
|
||||
<input type="date" name="start_date" id="start_date" required class="form-input" value="{{ prefill_start_date or '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Time') }}</label>
|
||||
<input type="time" name="start_time" id="start_time" required class="form-input">
|
||||
<input type="time" name="start_time" id="start_time" required class="form-input" value="{{ prefill_start_time or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Date') }}</label>
|
||||
<input type="date" name="end_date" id="end_date" required class="form-input">
|
||||
<input type="date" name="end_date" id="end_date" required class="form-input" value="{{ prefill_end_date or '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Time') }}</label>
|
||||
<input type="time" name="end_time" id="end_time" required class="form-input">
|
||||
<input type="time" name="end_time" id="end_time" required class="form-input" value="{{ prefill_end_time or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,18 +197,82 @@ document.addEventListener('DOMContentLoaded', async function(){
|
||||
if (projectSelect.value){ loadTasks(projectSelect.value); }
|
||||
}
|
||||
|
||||
// Form validation: ensure either project or client is selected
|
||||
// Form validation: ensure either project or client is selected and validate time range
|
||||
// Use capture phase to run before other handlers
|
||||
const form = document.getElementById('manualEntryForm');
|
||||
if (form) {
|
||||
// Store original button state to restore if needed
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Validate project or client selection
|
||||
const projectVal = projectSelect ? projectSelect.value : '';
|
||||
const clientVal = clientSelect ? clientSelect.value : '';
|
||||
if (!projectVal && !clientVal) {
|
||||
e.preventDefault();
|
||||
alert('{{ _("Please select either a project or a client") }}');
|
||||
e.stopImmediatePropagation(); // Stop other handlers from running
|
||||
|
||||
// Ensure button state is preserved
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Show error message using toast notification
|
||||
const errorMsg = '{{ _("Please select either a project or a client") }}';
|
||||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||||
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
// After showing error, ensure button is still in correct state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate time range before submission
|
||||
const startDate = document.getElementById('start_date');
|
||||
const startTime = document.getElementById('start_time');
|
||||
const endDate = document.getElementById('end_date');
|
||||
const endTime = document.getElementById('end_time');
|
||||
|
||||
if (startDate && startTime && endDate && endTime &&
|
||||
startDate.value && startTime.value && endDate.value && endTime.value) {
|
||||
const start = new Date(`${startDate.value}T${startTime.value}:00`);
|
||||
const end = new Date(`${endDate.value}T${endTime.value}:00`);
|
||||
|
||||
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
||||
if (end <= start) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // Stop other handlers from running
|
||||
|
||||
// Ensure button state is preserved
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Show error message using toast notification
|
||||
const errorMsg = '{{ _("End time must be after start time") }}';
|
||||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||||
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
// After showing error, ensure button is still in correct state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, true); // Use capture phase to run before other handlers
|
||||
}
|
||||
|
||||
// Apply Time Entry Template if provided via sessionStorage or query param
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user