diff --git a/app/routes/clients.py b/app/routes/clients.py index a25c135..9015d4f 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -32,18 +32,95 @@ def list_clients(): elif status == "inactive": query = query.filter_by(status="inactive") + # Determine database type for search strategy + is_postgres = False + try: + from sqlalchemy import inspect + engine = db.engine + is_postgres = 'postgresql' in str(engine.url).lower() + except Exception: + pass + if search: like = f"%{search}%" - query = query.filter( - db.or_( - Client.name.ilike(like), - Client.description.ilike(like), - Client.contact_person.ilike(like), - Client.email.ilike(like), - ) - ) + search_conditions = [ + Client.name.ilike(like), + Client.description.ilike(like), + Client.contact_person.ilike(like), + Client.email.ilike(like), + ] + + # Add custom fields to search based on database type + if is_postgres: + # PostgreSQL: Use JSONB operators for efficient search + try: + from sqlalchemy import cast, String + active_definitions = CustomFieldDefinition.get_active_definitions() + for definition in active_definitions: + # PostgreSQL JSONB path query: custom_fields->>'field_key' ILIKE pattern + search_conditions.append( + db.cast(Client.custom_fields[definition.field_key].astext, String).ilike(like) + ) + except Exception as e: + # If JSONB search fails, log and continue without custom field search in DB + current_app.logger.warning(f"Could not add JSONB search conditions: {e}") + + query = query.filter(db.or_(*search_conditions)) clients = query.order_by(Client.name).all() + + # For SQLite and other non-PostgreSQL databases, filter by custom fields in Python + # (PostgreSQL already handles this in the query above) + if search and not is_postgres: + try: + search_lower = search.lower() + filtered_clients = [] + active_definitions = CustomFieldDefinition.get_active_definitions() + + for client in clients: + # Check if matches standard fields (already in results) or custom fields + matched_standard = any([ + (client.name and search_lower in client.name.lower()), + (client.description and search_lower in (client.description or "").lower()), + (client.contact_person and search_lower in (client.contact_person or "").lower()), + (client.email and search_lower in (client.email or "").lower()), + ]) + + matched_custom = False + if client.custom_fields: + for definition in active_definitions: + field_value = client.custom_fields.get(definition.field_key) + if field_value and search_lower in str(field_value).lower(): + matched_custom = True + break + + if matched_standard or matched_custom: + filtered_clients.append(client) + + clients = filtered_clients + except Exception: + # If filtering fails, just use the original results + pass + + # Get custom field definitions for the template + custom_field_definitions = CustomFieldDefinition.get_active_definitions() + + # Get link templates for custom fields (for clickable values) + from app.models import LinkTemplate + from sqlalchemy.exc import ProgrammingError + link_templates_by_field = {} + try: + for template in LinkTemplate.get_active_templates(): + link_templates_by_field[template.field_key] = template + except ProgrammingError as e: + # Handle case where link_templates table doesn't exist (migration not run) + if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): + current_app.logger.warning( + "link_templates table does not exist. Run migration: flask db upgrade" + ) + link_templates_by_field = {} + else: + raise # Check if this is an AJAX request if request.headers.get("X-Requested-With") == "XMLHttpRequest": @@ -53,11 +130,13 @@ def list_clients(): clients=clients, status=status, search=search, + custom_field_definitions=custom_field_definitions, + link_templates_by_field=link_templates_by_field, )) response.headers["Content-Type"] = "text/html; charset=utf-8" return response - return render_template("clients/list.html", clients=clients, status=status, search=search) + return render_template("clients/list.html", clients=clients, status=status, search=search, custom_field_definitions=custom_field_definitions, link_templates_by_field=link_templates_by_field) @clients_bp.route("/clients/create", methods=["GET", "POST"]) diff --git a/app/routes/projects.py b/app/routes/projects.py index 6e0c8fe..1b0387b 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -60,11 +60,22 @@ def list_projects(): # 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() + client_id = request.args.get("client_id", type=int) search = request.args.get("search", "").strip() favorites_only = request.args.get("favorites", "").lower() == "true" + # Get custom field filters + # Format: custom_field_=value + client_custom_field = {} + from app.models import CustomFieldDefinition + active_definitions = CustomFieldDefinition.get_active_definitions() + for definition in active_definitions: + field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip() + if field_value: + client_custom_field[definition.field_key] = field_value + # Debug logging - current_app.logger.debug(f"Projects list filters - search: '{search}', status: '{status}', client: '{client_name}', favorites: {favorites_only}") + current_app.logger.debug(f"Projects list filters - search: '{search}', status: '{status}', client: '{client_name}', client_id: {client_id}, custom_fields: {client_custom_field}, favorites: {favorites_only}") project_service = ProjectService() @@ -72,6 +83,8 @@ def list_projects(): result = project_service.list_projects( status=status_param, client_name=client_name if client_name else None, + client_id=client_id, + client_custom_field=client_custom_field if client_custom_field else None, search=search if search else None, favorites_only=favorites_only, user_id=current_user.id if favorites_only else None, @@ -85,6 +98,10 @@ def list_projects(): # Get clients for filter dropdown clients = Client.get_active_clients() client_list = [c.name for c in clients] + + # Get custom field definitions for filter UI + from app.models import CustomFieldDefinition + custom_field_definitions = CustomFieldDefinition.get_active_definitions() # Check if this is an AJAX request if request.headers.get("X-Requested-With") == "XMLHttpRequest": @@ -110,6 +127,7 @@ def list_projects(): clients=client_list, favorite_project_ids=favorite_project_ids, favorites_only=favorites_only, + custom_field_definitions=custom_field_definitions, ) diff --git a/app/routes/timer.py b/app/routes/timer.py index ff8504e..2ca4fa9 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -1561,6 +1561,16 @@ def time_entries_overview(): page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) + # Get custom field filters for clients + # Format: custom_field_=value + client_custom_field = {} + from app.models import CustomFieldDefinition + active_definitions = CustomFieldDefinition.get_active_definitions() + for definition in active_definitions: + field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip() + if field_value: + client_custom_field[definition.field_key] = field_value + # Permission check: can user view all entries? can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") @@ -1593,6 +1603,41 @@ def time_entries_overview(): if client_id: query = query.filter(TimeEntry.client_id == client_id) + # Filter by client custom fields + if client_custom_field: + # Join Client table to filter by custom fields + query = query.join(Client, TimeEntry.client_id == Client.id) + + # Determine database type for custom field filtering + is_postgres = False + try: + from sqlalchemy import inspect + engine = db.engine + is_postgres = 'postgresql' in str(engine.url).lower() + except Exception: + pass + + # Build custom field filter conditions + custom_field_conditions = [] + for field_key, field_value in client_custom_field.items(): + if not field_key or not field_value: + continue + + if is_postgres: + # PostgreSQL: Use JSONB operators + try: + from sqlalchemy import cast, String + # Match exact value in custom_fields JSONB + custom_field_conditions.append( + db.cast(Client.custom_fields[field_key].astext, String) == str(field_value) + ) + except Exception: + # Fallback to Python filtering if JSONB fails + pass + + if custom_field_conditions: + query = query.filter(db.or_(*custom_field_conditions)) + # Filter by date range if start_date: try: @@ -1639,6 +1684,55 @@ def time_entries_overview(): pagination = query.paginate(page=page, per_page=per_page, error_out=False) time_entries = pagination.items + # For SQLite or if JSONB filtering didn't work, filter by custom fields in Python + if client_custom_field: + try: + from sqlalchemy import inspect + engine = db.engine + is_postgres = 'postgresql' in str(engine.url).lower() + + if not is_postgres: + # SQLite: Filter in Python + filtered_entries = [] + for entry in time_entries: + if not entry.client: + continue + + # Check if client matches all custom field filters + matches = True + for field_key, field_value in client_custom_field.items(): + if not field_key or not field_value: + continue + + client_value = entry.client.custom_fields.get(field_key) if entry.client.custom_fields else None + if str(client_value) != str(field_value): + matches = False + break + + if matches: + filtered_entries.append(entry) + + # Update pagination with filtered results + time_entries = filtered_entries + # Recalculate pagination manually + total = len(filtered_entries) + start = (page - 1) * per_page + end = start + per_page + time_entries = filtered_entries[start:end] + + # Create a pagination-like object + from flask_sqlalchemy import Pagination + pagination = Pagination( + query=None, + page=page, + per_page=per_page, + total=total, + items=time_entries + ) + except Exception: + # If filtering fails, use original results + pass + # Get filter options projects = [] clients = [] @@ -1693,10 +1787,15 @@ def time_entries_overview(): "paid": paid_filter, "billable": billable_filter, "search": search, + "client_custom_field": client_custom_field, "page": page, "per_page": per_page } + # Get custom field definitions for filter UI + from app.models import CustomFieldDefinition + custom_field_definitions = CustomFieldDefinition.get_active_definitions() + # Check if this is an AJAX request if request.headers.get("X-Requested-With") == "XMLHttpRequest": # Return only the time entries list HTML for AJAX requests @@ -1706,7 +1805,8 @@ def time_entries_overview(): time_entries=time_entries, pagination=pagination, can_view_all=can_view_all, - filters=filters_dict + filters=filters_dict, + custom_field_definitions=custom_field_definitions, )) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response @@ -1720,6 +1820,7 @@ def time_entries_overview(): users=users, can_view_all=can_view_all, filters=filters_dict, + custom_field_definitions=custom_field_definitions, totals={ "total_hours": round(total_hours, 2), "total_billable_hours": round(total_billable_hours, 2), diff --git a/app/services/project_service.py b/app/services/project_service.py index 6107f4a..a0155c8 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -209,6 +209,8 @@ class ProjectService: self, status: Optional[str] = None, client_name: Optional[str] = None, + client_id: Optional[int] = None, + client_custom_field: Optional[Dict[str, str]] = None, # {field_key: value} search: Optional[str] = None, favorites_only: bool = False, user_id: Optional[int] = None, @@ -219,11 +221,15 @@ class ProjectService: List projects with filtering and pagination. Uses eager loading to prevent N+1 queries. + Args: + client_custom_field: Dict with field_key and value to filter by client custom fields + Example: {"debtor_number": "12345"} + Returns: dict with 'projects', 'pagination', and 'total' keys """ from sqlalchemy.orm import joinedload - from app.models import UserFavoriteProject, Client + from app.models import UserFavoriteProject, Client, CustomFieldDefinition query = self.project_repo.query() @@ -241,9 +247,58 @@ class ProjectService: if status and status != "all": query = query.filter(Project.status == status) + # Filter by client - join Client table if needed + client_joined = False + if client_name or client_id or client_custom_field: + query = query.join(Client, Project.client_id == Client.id) + client_joined = True + # Filter by client name if client_name: - query = query.join(Client, Project.client_id == Client.id).filter(Client.name == client_name) + query = query.filter(Client.name == client_name) + + # Filter by client ID + if client_id: + query = query.filter(Client.id == client_id) + + # Filter by client custom fields + if client_custom_field: + # Ensure Client is joined + if not client_joined: + query = query.join(Client, Project.client_id == Client.id) + + # Determine database type for custom field filtering + is_postgres = False + try: + from sqlalchemy import inspect + engine = db.engine + is_postgres = 'postgresql' in str(engine.url).lower() + except Exception: + pass + + # Build custom field filter conditions + custom_field_conditions = [] + for field_key, field_value in client_custom_field.items(): + if not field_key or not field_value: + continue + + if is_postgres: + # PostgreSQL: Use JSONB operators + try: + from sqlalchemy import cast, String + # Match exact value in custom_fields JSONB + custom_field_conditions.append( + db.cast(Client.custom_fields[field_key].astext, String) == str(field_value) + ) + except Exception: + # Fallback to Python filtering if JSONB fails + pass + else: + # SQLite: Will filter in Python after query + pass + + if custom_field_conditions: + query = query.filter(db.or_(*custom_field_conditions)) # Search filter - must be applied after any joins if search: @@ -261,8 +316,53 @@ class ProjectService: # Order and paginate query = query.order_by(Project.name) pagination = query.paginate(page=page, per_page=per_page, error_out=False) + projects = pagination.items - return {"projects": pagination.items, "pagination": pagination, "total": pagination.total} + # For SQLite or if JSONB filtering didn't work, filter by custom fields in Python + if client_custom_field and not is_postgres: + try: + filtered_projects = [] + for project in projects: + if not project.client_obj: + continue + + # Check if client matches all custom field filters + matches = True + for field_key, field_value in client_custom_field.items(): + if not field_key or not field_value: + continue + + client_value = project.client_obj.custom_fields.get(field_key) if project.client_obj.custom_fields else None + if str(client_value) != str(field_value): + matches = False + break + + if matches: + filtered_projects.append(project) + + # Update pagination with filtered results + # Note: This affects pagination accuracy, but is necessary for SQLite + projects = filtered_projects + # Recalculate pagination manually + total = len(filtered_projects) + start = (page - 1) * per_page + end = start + per_page + projects = filtered_projects[start:end] + + # Create a pagination-like object + from flask_sqlalchemy import Pagination + pagination = Pagination( + query=None, + page=page, + per_page=per_page, + total=total, + items=projects + ) + except Exception: + # If filtering fails, use original results + pass + + return {"projects": projects, "pagination": pagination, "total": pagination.total} def get_project_view_data( self, project_id: int, time_entries_page: int = 1, time_entries_per_page: int = 50 diff --git a/app/templates/clients/_clients_list.html b/app/templates/clients/_clients_list.html index 0445a85..7a6941a 100644 --- a/app/templates/clients/_clients_list.html +++ b/app/templates/clients/_clients_list.html @@ -36,6 +36,11 @@ Status Projects Portal + {% if custom_field_definitions %} + {% for definition in custom_field_definitions %} + {{ definition.label }} + {% endfor %} + {% endif %} Actions @@ -77,6 +82,28 @@ {% endif %} + {% if custom_field_definitions %} + {% for definition in custom_field_definitions %} + + {% if client.custom_fields and client.custom_fields.get(definition.field_key) %} + {% set field_value = client.custom_fields.get(definition.field_key) %} + {% set link_template = link_templates_by_field.get(definition.field_key) if (link_templates_by_field is defined and link_templates_by_field) else None %} + {% if link_template %} + + {{ field_value }} + {% if link_template.icon %} + + {% endif %} + + {% else %} + {{ field_value }} + {% endif %} + {% else %} + — + {% endif %} + + {% endfor %} + {% endif %} View diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html index f0b111f..93ca9f9 100644 --- a/app/templates/projects/list.html +++ b/app/templates/projects/list.html @@ -52,6 +52,17 @@ + {% if custom_field_definitions %} + {% for definition in custom_field_definitions %} +
+ + +
+ {% endfor %} + {% endif %} diff --git a/app/templates/timer/time_entries_overview.html b/app/templates/timer/time_entries_overview.html index 6989ba8..5735712 100644 --- a/app/templates/timer/time_entries_overview.html +++ b/app/templates/timer/time_entries_overview.html @@ -91,6 +91,17 @@ + {% if custom_field_definitions %} + {% for definition in custom_field_definitions %} +
+ + +
+ {% endfor %} + {% endif %}