feat: Add time entries overview page with AJAX filters and bulk mark as paid

- Add new /time-entries route with comprehensive filtering
  - Filter by user (admin only), project, client, date range
  - Filter by paid/unpaid status and billable status
  - Search in notes and tags
  - Pagination support (50 entries per page)

- Implement bulk mark as paid/unpaid functionality
  - Select multiple entries with checkboxes
  - Bulk actions menu to mark selected entries as paid or unpaid
  - Preserves filters after bulk operations
  - Activity logging for bulk changes

- Add AJAX filtering similar to projects/tasks pages
  - Auto-apply filters on dropdown/date changes (100ms debounce)
  - Auto-apply search as you type (500ms debounce)
  - Updates URL without page reload
  - Partial template rendering for AJAX requests

- Add navigation menu link in sidebar under Work section
- Extend bulk entries API to support set_paid action
- Add summary cards showing total hours, billable hours, paid hours, and entry count
- Permission-based access: admins see all entries, regular users see only their own
This commit is contained in:
Dries Peeters
2025-12-01 14:15:58 +01:00
parent 9112a696dd
commit de266dbf7d
5 changed files with 852 additions and 2 deletions
+9 -2
View File
@@ -838,7 +838,7 @@ def create_entry():
@api_bp.route("/api/entries/bulk", methods=["POST"])
@login_required
def bulk_entries_action():
"""Perform bulk actions on time entries: delete, set billable, add/remove tag."""
"""Perform bulk actions on time entries: delete, set billable, set paid, add/remove tag."""
data = request.get_json() or {}
entry_ids = data.get("entry_ids") or []
action = (data.get("action") or "").strip()
@@ -846,7 +846,7 @@ def bulk_entries_action():
if not entry_ids or not isinstance(entry_ids, list):
return jsonify({"error": "entry_ids must be a non-empty list"}), 400
if action not in {"delete", "set_billable", "add_tag", "remove_tag"}:
if action not in {"delete", "set_billable", "set_paid", "add_tag", "remove_tag"}:
return jsonify({"error": "Unsupported action"}), 400
# Load entries with permission checks
@@ -876,6 +876,13 @@ def bulk_entries_action():
e.billable = flag
e.updated_at = local_now()
affected += 1
elif action == "set_paid":
flag = bool(value)
for e in entries:
if e.is_active:
continue
e.set_paid(flag)
affected += 1
elif action in {"add_tag", "remove_tag"}:
tag = (value or "").strip()
if not tag:
+289
View File
@@ -1539,3 +1539,292 @@ def resume_timer(timer_id):
flash(_("Timer resumed"), "success")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/time-entries")
@login_required
def time_entries_overview():
"""Overview page showing all time entries with filters and bulk actions"""
from sqlalchemy import or_, func, desc
from sqlalchemy.orm import joinedload
from app.repositories import TimeEntryRepository, ProjectRepository, UserRepository
# Get filter parameters
user_id = request.args.get("user_id", type=int)
project_id = request.args.get("project_id", type=int)
client_id = request.args.get("client_id", type=int)
start_date = request.args.get("start_date", "")
end_date = request.args.get("end_date", "")
paid_filter = request.args.get("paid", "") # "true", "false", or ""
billable_filter = request.args.get("billable", "") # "true", "false", or ""
search = request.args.get("search", "").strip()
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
# Permission check: can user view all entries?
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
# Build query with eager loading to avoid N+1 queries
query = TimeEntry.query.options(
joinedload(TimeEntry.user),
joinedload(TimeEntry.project),
joinedload(TimeEntry.client),
joinedload(TimeEntry.task)
).filter(TimeEntry.end_time.isnot(None)) # Only completed entries
# Filter by user
if user_id:
if can_view_all:
query = query.filter(TimeEntry.user_id == user_id)
elif user_id == current_user.id:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
flash(_("You do not have permission to view other users' time entries"), "error")
return redirect(url_for("timer.time_entries_overview"))
elif not can_view_all:
# Non-admin users can only see their own entries
query = query.filter(TimeEntry.user_id == current_user.id)
# Filter by project
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
# Filter by client
if client_id:
query = query.filter(TimeEntry.client_id == client_id)
# Filter by date range
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TimeEntry.start_time >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# Include the entire end date
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(TimeEntry.start_time <= end_dt)
except ValueError:
pass
# Filter by paid status
if paid_filter == "true":
query = query.filter(TimeEntry.paid == True)
elif paid_filter == "false":
query = query.filter(TimeEntry.paid == False)
# Filter by billable status
if billable_filter == "true":
query = query.filter(TimeEntry.billable == True)
elif billable_filter == "false":
query = query.filter(TimeEntry.billable == False)
# Search in notes and tags
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
TimeEntry.notes.ilike(search_pattern),
TimeEntry.tags.ilike(search_pattern)
)
)
# Order by start time (most recent first)
query = query.order_by(desc(TimeEntry.start_time))
# Pagination
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
time_entries = pagination.items
# Get filter options
projects = []
clients = []
users = []
if can_view_all:
project_repo = ProjectRepository()
projects = project_repo.get_active_projects()
clients = Client.query.filter_by(status="active").order_by(Client.name).all()
user_repo = UserRepository()
users = user_repo.get_active_users()
else:
# For non-admin users, only show their projects
# Get projects from user's time entries
user_project_ids = (
db.session.query(TimeEntry.project_id)
.filter(TimeEntry.user_id == current_user.id, TimeEntry.project_id.isnot(None))
.distinct()
.all()
)
user_project_ids = [pid[0] for pid in user_project_ids]
if user_project_ids:
projects = Project.query.filter(Project.id.in_(user_project_ids), Project.status == "active").order_by(Project.name).all()
# Get clients from user's projects
client_ids = set(p.client_id for p in projects if p.client_id)
if client_ids:
clients = Client.query.filter(Client.id.in_(client_ids), Client.status == "active").order_by(Client.name).all()
users = [current_user]
# Calculate totals
total_hours = sum(entry.duration_hours for entry in time_entries)
total_billable_hours = sum(entry.duration_hours for entry in time_entries if entry.billable)
total_paid_hours = sum(entry.duration_hours for entry in time_entries if entry.paid)
# Track page view
track_event(
current_user.id,
"time_entries_overview.viewed",
{
"has_filters": bool(user_id or project_id or client_id or start_date or end_date or paid_filter or billable_filter or search),
"page": page,
"per_page": per_page
}
)
filters_dict = {
"user_id": user_id,
"project_id": project_id,
"client_id": client_id,
"start_date": start_date,
"end_date": end_date,
"paid": paid_filter,
"billable": billable_filter,
"search": search,
"page": page,
"per_page": per_page
}
# 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
from flask import make_response
response = make_response(render_template(
"timer/_time_entries_list.html",
time_entries=time_entries,
pagination=pagination,
can_view_all=can_view_all,
filters=filters_dict
))
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
return render_template(
"timer/time_entries_overview.html",
time_entries=time_entries,
pagination=pagination,
projects=projects,
clients=clients,
users=users,
can_view_all=can_view_all,
filters=filters_dict,
totals={
"total_hours": round(total_hours, 2),
"total_billable_hours": round(total_billable_hours, 2),
"total_paid_hours": round(total_paid_hours, 2),
"total_entries": len(time_entries)
}
)
@timer_bp.route("/time-entries/bulk-paid", methods=["POST"])
@login_required
def bulk_mark_paid():
"""Bulk mark time entries as paid or unpaid"""
from app.utils.db import safe_commit
entry_ids = request.form.getlist("entry_ids[]")
paid_status = request.form.get("paid", "").strip().lower()
if not entry_ids:
flash(_("No time entries selected"), "warning")
return redirect(url_for("timer.time_entries_overview"))
if paid_status not in ("true", "false"):
flash(_("Invalid paid status"), "error")
return redirect(url_for("timer.time_entries_overview"))
is_paid = paid_status == "true"
# Load entries
entry_ids_int = [int(eid) for eid in entry_ids if eid.isdigit()]
if not entry_ids_int:
flash(_("Invalid entry IDs"), "error")
return redirect(url_for("timer.time_entries_overview"))
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids_int)).all()
if not entries:
flash(_("No time entries found"), "error")
return redirect(url_for("timer.time_entries_overview"))
# Permission check
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
updated_count = 0
skipped_count = 0
for entry in entries:
# Check permissions
if not can_view_all and entry.user_id != current_user.id:
skipped_count += 1
continue
# Skip active timers
if entry.is_active:
skipped_count += 1
continue
# Update paid status
entry.set_paid(is_paid)
updated_count += 1
# Log activity
Activity.log(
user_id=current_user.id,
action="updated",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"Time entry #{entry.id}",
description=f"Marked time entry as {'paid' if is_paid else 'unpaid'}",
extra_data={"paid": is_paid, "project_id": entry.project_id, "client_id": entry.client_id},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
if updated_count > 0:
if not safe_commit("bulk_mark_paid", {"count": updated_count, "paid": is_paid}):
flash(_("Could not update time entries due to a database error. Please check server logs."), "error")
return redirect(url_for("timer.time_entries_overview"))
flash(
_("Successfully marked %(count)d time entry/entries as %(status)s", count=updated_count, status=_("paid") if is_paid else _("unpaid")),
"success"
)
if skipped_count > 0:
flash(
_("Skipped %(count)d time entry/entries (no permission or active timer)", count=skipped_count),
"warning"
)
# Track event
track_event(
current_user.id,
"time_entries.bulk_mark_paid",
{"count": updated_count, "paid": is_paid}
)
# Preserve filters in redirect
redirect_url = url_for("timer.time_entries_overview")
filters = {}
for key in ["user_id", "project_id", "client_id", "start_date", "end_date", "paid", "billable", "search", "page"]:
value = request.form.get(key) or request.args.get(key)
if value:
filters[key] = value
if filters:
redirect_url += "?" + "&".join(f"{k}={v}" for k, v in filters.items())
return redirect(redirect_url)
+6
View File
@@ -275,6 +275,7 @@
</button>
<ul id="workDropdown" class="{% if not work_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_timer = ep.startswith('timer.') %}
{% set nav_active_time_entries = ep == 'timer.time_entries_overview' %}
{% set nav_active_projects = ep.startswith('projects.') %}
{% set nav_active_clients = ep.startswith('clients.') %}
{% set nav_active_quotes = ep.startswith('quotes.') %}
@@ -288,6 +289,11 @@
<i class="fas fa-clock w-4 mr-2"></i>{{ _('Log Time') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_time_entries %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.time_entries_overview') }}">
<i class="fas fa-list-alt w-4 mr-2"></i>{{ _('Time Entries') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">
<i class="fas fa-folder w-4 mr-2"></i>{{ _('Projects') }}
+159
View File
@@ -0,0 +1,159 @@
<div id="timeEntriesListContainer">
<div class="p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleSelectAll()">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Select All') }}</span>
</label>
<span id="selectedCount" class="text-sm text-gray-600 dark:text-gray-400 hidden">
<span id="countValue">0</span> {{ _('selected') }}
</span>
</div>
<div class="flex items-center gap-2">
<button type="button" id="bulkActionsBtn" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" onclick="showBulkPaidDialog()" disabled>
<i class="fas fa-tasks mr-2"></i>{{ _('Bulk Actions') }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
<thead class="bg-background-light dark:bg-background-dark">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12">
<input type="checkbox" id="selectAllHeader" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleSelectAll()">
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Date') }}</th>
{% if can_view_all %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('User') }}</th>
{% endif %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Project/Client') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Task') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Duration') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Notes') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Tags') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Status') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody class="bg-card-light dark:bg-card-dark divide-y divide-border-light dark:divide-border-dark">
{% if time_entries %}
{% for entry in time_entries %}
<tr class="hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<input type="checkbox" name="entry_ids[]" value="{{ entry.id }}" class="entry-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="updateBulkActions()">
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') if entry.start_time else '-' }}
</td>
{% if can_view_all %}
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ entry.user.display_name if entry.user else '-' }}
</td>
{% endif %}
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{% if entry.project %}
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}" class="text-primary hover:underline">
{{ entry.project.name }}
</a>
{% elif entry.client %}
<a href="{{ url_for('clients.view_client', client_id=entry.client.id) }}" class="text-primary hover:underline">
{{ entry.client.name }}
</a>
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{% if entry.task %}
<a href="{{ url_for('tasks.view_task', task_id=entry.task.id) }}" class="text-primary hover:underline">
{{ entry.task.name }}
</a>
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ entry.duration_formatted }}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 max-w-xs truncate">
{{ entry.notes or '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{% if entry.tags %}
{% for tag in entry.tag_list %}
<span class="inline-block bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded text-xs mr-1 mb-1">{{ tag }}</span>
{% endfor %}
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if entry.paid %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-check-circle mr-1"></i>{{ _('Paid') }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
<i class="fas fa-clock mr-1"></i>{{ _('Unpaid') }}
</span>
{% endif %}
{% if entry.billable %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 ml-1">
<i class="fas fa-dollar-sign mr-1"></i>{{ _('Billable') }}
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary/80 mr-2" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="{% if can_view_all %}10{% else %}9{% endif %}" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
{{ _('No time entries found') }}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="px-4 py-3 border-t border-border-light dark:border-border-dark flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-300">
{{ _('Showing %(start)s to %(end)s of %(total)s entries', start=pagination.page * pagination.per_page - pagination.per_page + 1, end=pagination.page * pagination.per_page if pagination.page * pagination.per_page < pagination.total else pagination.total, total=pagination.total) }}
</div>
<div class="flex gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('timer.time_entries_overview', page=pagination.prev_num, **filters) }}" class="px-3 py-1 bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
{{ _('Previous') }}
</a>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<span class="px-3 py-1 bg-primary text-white rounded-lg">{{ page_num }}</span>
{% else %}
<a href="{{ url_for('timer.time_entries_overview', page=page_num, **filters) }}" class="px-3 py-1 bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
{{ page_num }}
</a>
{% endif %}
{% else %}
<span class="px-3 py-1">...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('timer.time_entries_overview', page=pagination.next_num, **filters) }}" class="px-3 py-1 bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
{{ _('Next') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
@@ -0,0 +1,389 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, stat_card, badge %}
{% block title %}{{ _('Time Entries Overview') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Time Entries')}
] %}
{{ page_header(
icon_class='fas fa-clock',
title_text=_('Time Entries Overview'),
subtitle_text=_('View and manage all time entries'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<!-- Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{{ stat_card(_('Total Hours'), totals.total_hours, 'fas fa-hourglass-half', 'blue-500') }}
{{ stat_card(_('Billable Hours'), totals.total_billable_hours, 'fas fa-dollar-sign', 'green-500') }}
{{ stat_card(_('Paid Hours'), totals.total_paid_hours, 'fas fa-check-circle', 'emerald-500') }}
{{ stat_card(_('Entries'), totals.total_entries, 'fas fa-list', 'purple-500') }}
</div>
<!-- Filters -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">{{ _('Filters') }}</h2>
<button type="button" 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" id="toggleFilters" onclick="toggleFilterVisibility()">
<i class="fas fa-chevron-up" id="filterToggleIcon"></i>
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" id="timeEntriesFilterForm" data-filter-form>
{% if can_view_all %}
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('User') }}</label>
<select name="user_id" id="user_id" class="form-input">
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if filters.user_id == user.id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Project') }}</label>
<select name="project_id" id="project_id" class="form-input">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if filters.project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Client') }}</label>
<select name="client_id" id="client_id" class="form-input">
<option value="">{{ _('All Clients') }}</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if filters.client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Start Date') }}</label>
<input type="date" name="start_date" id="start_date" value="{{ filters.start_date or '' }}" class="form-input">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('End Date') }}</label>
<input type="date" name="end_date" id="end_date" value="{{ filters.end_date or '' }}" class="form-input">
</div>
<div>
<label for="paid" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Paid Status') }}</label>
<select name="paid" id="paid" class="form-input">
<option value="">{{ _('All') }}</option>
<option value="true" {% if filters.paid == 'true' %}selected{% endif %}>{{ _('Paid') }}</option>
<option value="false" {% if filters.paid == 'false' %}selected{% endif %}>{{ _('Unpaid') }}</option>
</select>
</div>
<div>
<label for="billable" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Billable Status') }}</label>
<select name="billable" id="billable" class="form-input">
<option value="">{{ _('All') }}</option>
<option value="true" {% if filters.billable == 'true' %}selected{% endif %}>{{ _('Billable') }}</option>
<option value="false" {% if filters.billable == 'false' %}selected{% endif %}>{{ _('Non-billable') }}</option>
</select>
</div>
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Search') }}</label>
<input type="text" name="search" id="search" value="{{ filters.search or '' }}" placeholder="{{ _('Search in notes and tags...') }}" class="form-input">
</div>
</form>
</div>
</div>
<!-- Bulk Actions Form -->
<form id="bulkPaidForm" method="POST" action="{{ url_for('timer.bulk_mark_paid') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="paid" id="bulkPaidValue">
{% for key, value in filters.items() %}
{% if value %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
</form>
<!-- Time Entries Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
{% include 'timer/_time_entries_list.html' %}
</div>
<!-- Bulk Mark Paid Dialog -->
<div id="bulkPaidDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Mark Selected Entries as Paid/Unpaid') }}</h3>
<label for="bulkPaidSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Status') }}</label>
<select id="bulkPaidSelect" class="form-input w-full mb-4">
<option value="true">{{ _('Paid') }}</option>
<option value="false">{{ _('Unpaid') }}</option>
</select>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkPaidDialog()">
{{ _('Cancel') }}
</button>
<button type="button" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors" onclick="submitBulkPaid()">
{{ _('Update') }}
</button>
</div>
</div>
</div>
</div>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const icon = document.getElementById('filterToggleIcon');
filterBody.classList.toggle('hidden');
icon.classList.toggle('fa-chevron-up');
icon.classList.toggle('fa-chevron-down');
}
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
const selectAllHeader = document.getElementById('selectAllHeader');
const checkboxes = document.querySelectorAll('.entry-checkbox');
const checked = selectAll.checked || selectAllHeader.checked;
checkboxes.forEach(cb => cb.checked = checked);
selectAll.checked = checked;
selectAllHeader.checked = checked;
updateBulkActions();
}
function updateBulkActions() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
const count = checkboxes.length;
const countValue = document.getElementById('countValue');
const selectedCount = document.getElementById('selectedCount');
const bulkActionsBtn = document.getElementById('bulkActionsBtn');
if (count > 0) {
countValue.textContent = count;
selectedCount.classList.remove('hidden');
bulkActionsBtn.disabled = false;
} else {
selectedCount.classList.add('hidden');
bulkActionsBtn.disabled = true;
}
}
function showBulkPaidDialog() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
if (checkboxes.length === 0) {
return;
}
document.getElementById('bulkPaidDialog').classList.remove('hidden');
}
function closeBulkPaidDialog() {
document.getElementById('bulkPaidDialog').classList.add('hidden');
}
function submitBulkPaid() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
const form = document.getElementById('bulkPaidForm');
const paidValue = document.getElementById('bulkPaidSelect').value;
// Add selected entry IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'entry_ids[]';
input.value = cb.value;
form.appendChild(input);
});
document.getElementById('bulkPaidValue').value = paidValue;
form.submit();
}
// Time Entries Filter Handler - AJAX filtering
(function() {
'use strict';
let filterTimeout = null;
let searchTimeout = null;
function getFilterParams() {
const form = document.getElementById('timeEntriesFilterForm');
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;
}
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/time-entries?${queryString}`;
}
function applyFilters() {
const url = buildFilterUrl();
const container = document.getElementById('timeEntriesListContainer');
if (!container) {
console.error('timeEntriesListContainer 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('#timeEntriesListContainer');
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
} else {
// Fallback: try to extract content using regex
const match = html.trim().match(/<div[^>]*id=["']timeEntriesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
}
}
// Re-initialize bulk actions after content update
updateBulkActions();
})
.catch(error => {
console.error('Filter error:', error);
container.style.opacity = '';
container.style.pointerEvents = '';
if (window.toastManager) {
window.toastManager.show('Failed to filter time entries. Please refresh the page.', 'error');
} else if (window.showToast) {
window.showToast('Failed to filter time entries. 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('timeEntriesFilterForm');
if (!form) {
console.error('Time entries filter form not found');
return;
}
// Auto-submit on dropdown changes
form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
debouncedApplyFilters(100);
});
});
// Auto-submit on date input changes
form.querySelectorAll('input[type="date"]').forEach(input => {
input.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 %}