mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 22:00:07 -05:00
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:
+9
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user