feat: Add bulk selection and actions to expenses, payments, per diem, and mileage tables

Standardize table templates across all list pages by adding bulk selection
and bulk actions functionality, following the same pattern as the tasks table.

Changes:
- Add bulk actions dropdown button (admin only) with "Change Status" and "Delete" options
- Add checkbox column in table headers with select-all functionality
- Add individual checkboxes for each row
- Implement JavaScript functions for bulk operations:
  * toggleAll[Entity]() - Select/deselect all checkboxes
  * updateBulkDeleteButton() - Update button state based on selection count
  * Menu management (openMenu, closeAllMenus)
  * Bulk delete confirmation dialog
  * Bulk status change dialog
- Add hidden forms for bulk delete and bulk status change operations
- Add confirmation dialogs for bulk operations with proper styling
- Maintain consistent UI/UX across all entity list pages

Note: Backend routes for bulk operations need to be implemented (forms
currently use placeholder action="#"). The frontend is ready for backend
integration.

Affected files:
- app/templates/expenses/list.html
- app/templates/payments/list.html
- app/templates/per_diem/list.html
- app/templates/mileage/list.html
This commit is contained in:
Dries Peeters
2025-11-05 06:50:27 +01:00
parent 4e25f305e9
commit b937f4e2d9
7 changed files with 1776 additions and 382 deletions

View File

@@ -15,8 +15,14 @@
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Clients</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Clients</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()" title="{{ _('Toggle Filters') }}">
<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-3 gap-4" 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">
@@ -29,10 +35,11 @@
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
<div class="self-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
<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">
@@ -49,7 +56,7 @@
<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 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<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>
@@ -59,38 +66,38 @@
{% endif %}
</div>
</div>
<table class="w-full text-left">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-10">
<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">Name</th>
<th class="p-4">Contact Person</th>
<th class="p-4">Email</th>
<th class="p-4">Status</th>
<th class="p-4">Projects</th>
<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">Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr class="border-b border-border-light dark:border-border-dark">
<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">{{ client.name }}</td>
<td class="p-4">{{ client.contact_person }}</td>
<td class="p-4">{{ client.email }}</td>
<td class="p-4 font-medium">{{ client.name }}</td>
<td class="p-4">{{ client.contact_person or '—' }}</td>
<td class="p-4">{{ client.email or '—' }}</td>
<td class="p-4">
{% if client.status == 'active' %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
<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 bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
<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">
@@ -101,66 +108,166 @@
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No clients found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if current_user.is_admin %}
<form id="clients-bulk-status-form" method="POST" action="{{ url_for('clients.bulk_status_change') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="clientsBulkNewStatus" value="">
</form>
<form id="clients-bulk-delete-form" method="POST" action="{{ url_for('clients.bulk_delete_clients') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<script>
function toggleAllClients(){
const selectAll = document.getElementById('selectAll');
document.querySelectorAll('.client-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));
updateClientsBulkState();
}
function updateClientsBulkState(){
const selected = document.querySelectorAll('.client-checkbox:checked').length;
const btn = document.getElementById('bulkActionsBtn');
const cnt = document.getElementById('selectedCount');
if (cnt) cnt.textContent = selected;
if (btn) btn.disabled = selected === 0;
}
function showBulkDeleteConfirm(){
const count = document.querySelectorAll('.client-checkbox:checked').length;
if (count === 0) return false;
const msg = `Are you sure you want to delete ${count} client(s)? Clients with projects will be skipped.`;
if (window.showConfirm){ window.showConfirm(msg, { title: 'Delete Clients', confirmText: 'Delete', variant: 'danger' }).then(function(ok){ if (ok) submitClientsBulkDelete(); }); }
return false;
}
function submitClientsBulkDelete(){
const form = document.getElementById('clients-bulk-delete-form');
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
});
form.submit();
}
function showBulkStatusChange(newStatus){
const count = document.querySelectorAll('.client-checkbox:checked').length;
if (count === 0) return false;
const label = {active:'Active', inactive:'Inactive'}[newStatus] || newStatus;
const msg = `Are you sure you want to mark ${count} client(s) as ${label}?`;
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Client Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('clientsBulkNewStatus').value=newStatus; submitClientsBulkStatus(); }}); }
return false;
}
function submitClientsBulkStatus(){
const form = document.getElementById('clients-bulk-status-form');
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
});
form.submit();
}
</script>
{% 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 %}
{{ empty_state('fas fa-users', 'No Clients Found', 'Get started by creating your first client to organize your projects.', actions) }}
{% endif %}
</div>
{% if current_user.is_admin %}
<form id="clients-bulk-status-form" method="POST" action="{{ url_for('clients.bulk_status_change') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="clientsBulkNewStatus" value="">
</form>
<form id="clients-bulk-delete-form" method="POST" action="{{ url_for('clients.bulk_delete_clients') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
{% endif %}
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('clientListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('clientListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('clientListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
function toggleAllClients(){
const selectAll = document.getElementById('selectAll');
document.querySelectorAll('.client-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));
updateClientsBulkState();
}
function updateClientsBulkState(){
const selected = document.querySelectorAll('.client-checkbox:checked').length;
const btn = document.getElementById('bulkActionsBtn');
const cnt = document.getElementById('selectedCount');
if (cnt) cnt.textContent = selected;
if (btn) btn.disabled = selected === 0;
}
function closeAllMenus(){
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId){
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
// Position menu (dropup if not enough space below)
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16){
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
// Click outside to close
document.addEventListener('click', function(e){
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#clientsBulkMenu');
if (!insideTrigger && !insideMenu){ closeAllMenus(); }
});
// Close on Escape
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
function showBulkDeleteConfirm(){
const count = document.querySelectorAll('.client-checkbox:checked').length;
if (count === 0) return false;
const msg = `Are you sure you want to delete ${count} client(s)? Clients with projects will be skipped.`;
if (window.showConfirm){ window.showConfirm(msg, { title: 'Delete Clients', confirmText: 'Delete', variant: 'danger' }).then(function(ok){ if (ok) submitClientsBulkDelete(); }); }
return false;
}
function submitClientsBulkDelete(){
const form = document.getElementById('clients-bulk-delete-form');
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
});
form.submit();
}
function showBulkStatusChange(newStatus){
const count = document.querySelectorAll('.client-checkbox:checked').length;
if (count === 0) return false;
const label = {active:'Active', inactive:'Inactive'}[newStatus] || newStatus;
const msg = `Are you sure you want to mark ${count} client(s) as ${label}?`;
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Client Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('clientsBulkNewStatus').value=newStatus; submitClientsBulkStatus(); }}); }
return false;
}
function submitClientsBulkStatus(){
const form = document.getElementById('clients-bulk-status-form');
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
});
form.submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -61,8 +61,14 @@
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Expenses</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Expenses</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()" title="{{ _('Toggle Filters') }}">
<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" data-filter-form>
<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="{{ search or '' }}"
@@ -144,7 +150,7 @@
</select>
</div>
<div class="flex items-end gap-2">
<div class="flex items-end gap-2 col-span-full">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
@@ -156,108 +162,184 @@
</a>
</div>
</form>
</div>
</div>
{% if current_user.is_admin %}
<!-- Bulk Operations Forms (hidden) -->
<!-- Note: Bulk operations routes need to be implemented in the backend -->
<form id="confirmBulkDelete-form" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusForm" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" id="bulkStatusValue">
</form>
<!-- Bulk Delete Confirmation Dialog -->
<div id="confirmBulkDelete" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="closeBulkDeleteDialog()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">Delete Selected Expenses</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">Are you sure you want to delete the selected expenses? This action cannot be undone.</p>
</div>
</div>
<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="closeBulkDeleteDialog()">
Cancel
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="submitBulkDelete()">
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Status Change Dialog -->
<div id="bulkStatusDialog" 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">Change Status for Selected Expenses</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="reimbursed">Reimbursed</option>
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm 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">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Update Status</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Expenses Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4 p-6 pb-0">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ expenses|length }} expense{{ 's' if expenses|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('expenses.export_expenses', **request.args) }}" 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, 'expensesBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="expensesBulkMenu" 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>
<div class="overflow-x-auto p-6 pt-4">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Project</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
{% 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="toggleAllExpenses()">
</th>
{% endif %}
<th class="p-4" data-sortable>Date</th>
<th class="p-4" data-sortable>Title</th>
<th class="p-4" data-sortable>Category</th>
<th class="p-4 table-number" data-sortable>Amount</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>User</th>
<th class="p-4" data-sortable>Project</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{% if expenses %}
{% for expense in expenses %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ expense.expense_date.strftime('%Y-%m-%d') }}
<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="expense-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ expense.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="px-6 py-4 text-sm">
{% endif %}
<td class="p-4 whitespace-nowrap">{{ expense.expense_date.strftime('%Y-%m-%d') }}</td>
<td class="p-4">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline font-medium">
{{ expense.title }}
</a>
{% if expense.vendor %}
<div class="text-xs text-gray-500">{{ expense.vendor }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ expense.vendor }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<td class="p-4">
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ expense.category|title }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<td class="p-4 table-number font-medium">
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
{% if expense.billable %}
<span class="ml-1 text-xs text-green-600" title="Billable"><i class="fas fa-check-circle"></i></span>
<span class="ml-1 text-xs text-green-600 dark:text-green-400" title="Billable"><i class="fas fa-check-circle"></i></span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<td class="p-4">
{% if expense.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">Pending</span>
{% elif expense.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Approved</span>
{% elif expense.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Rejected</span>
{% elif expense.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ expense.user.username if expense.user else '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if expense.project %}
<a href="{{ url_for('projects.view_project', project_id=expense.project_id) }}" class="text-primary hover:underline">
{{ expense.project.name }}
</a>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Reimbursed</span>
{% else %}
-
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ expense.status|title }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or expense.user_id == current_user.id %}
<a href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
<td class="p-4">{{ expense.user.username if expense.user else '—' }}</td>
<td class="p-4">
{% if expense.project %}
<a href="{{ url_for('projects.view_project', project_id=expense.project.id) }}" class="text-primary hover:underline">{{ expense.project.name }}</a>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-receipt text-4xl mb-2 opacity-50"></i>
<p>No expenses found</p>
<a href="{{ url_for('expenses.create_expense') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first expense
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if not expenses %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('expenses.create_expense') }}" 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 Expense
</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 %}
{{ empty_state('fas fa-receipt', 'No Expenses Found', 'Get started by creating your first expense entry.', actions) }}
{% endif %}
</div>
<!-- Pagination -->
@@ -323,3 +405,180 @@
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('expenseListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('expenseListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('expenseListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
// Bulk actions for expenses
function toggleAllExpenses() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.expense-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.expense-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
}
function closeAllMenus() {
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId) {
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
// Position menu (dropup if not enough space below)
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16) {
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
// Click outside to close
document.addEventListener('click', function(e) {
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#expensesBulkMenu');
if (!insideTrigger && !insideMenu) {
closeAllMenus();
}
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllMenus();
});
function showBulkDeleteConfirm() {
const count = document.querySelectorAll('.expense-checkbox:checked').length;
if (count === 0) return false;
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
form.querySelectorAll('input[name="expense_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.expense-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'expense_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
// Clear existing hidden inputs
form.querySelectorAll('input[name="expense_ids[]"]').forEach(input => input.remove());
// Add selected expense IDs to form
const checkboxes = document.querySelectorAll('.expense-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'expense_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -26,44 +26,192 @@
{{ info_card("Overdue", "%.2f"|format(summary.overdue_amount), "All time") }}
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<table class="w-full text-left">
<thead>
<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">Filter Invoices</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()" title="{{ _('Toggle Filters') }}">
<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-3 gap-4" data-filter-form>
<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</option>
<option value="draft" {% if request.args.get('status') == 'draft' %}selected{% endif %}>Draft</option>
<option value="sent" {% if request.args.get('status') == 'sent' %}selected{% endif %}>Sent</option>
<option value="paid" {% if request.args.get('status') == 'paid' %}selected{% endif %}>Paid</option>
<option value="overdue" {% if request.args.get('status') == 'overdue' %}selected{% endif %}>Overdue</option>
</select>
</div>
<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="{{ 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" title="Export to Excel">
<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, '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">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-2">Number</th>
<th class="p-2">Client</th>
<th class="p-2">Status</th>
<th class="p-2">Total</th>
<th class="p-2">Actions</th>
{% 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 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">
<td class="p-2">{{ invoice.invoice_number }}</td>
<td class="p-2">{{ invoice.client_name }}</td>
<td class="p-2">{{ invoice.status }}</td>
<td class="p-2">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-2">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:text-primary-dark">View</a>
<span class="mx-1">|</span>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="text-secondary hover:text-secondary-dark">Edit</a>
<span class="mx-1">|</span>
<button type="button" onclick="showDeleteModal('{{ invoice.id }}', '{{ invoice.invoice_number }}')" class="text-red-500 hover:text-red-700">
Delete
</button>
<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">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">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">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">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 table-number">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-4 table-number">
{% if invoice.due_date %}
<span class="chip whitespace-nowrap chip-neutral">{{ 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">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="p-4 text-center">No invoices found.</td>
</tr>
{% 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 %}
{{ empty_state('fas fa-file-invoice', 'No Invoices Found', 'Get started by creating your first invoice to bill your clients.', actions) }}
{% endif %}
</div>
<!-- Delete Invoice Modal -->
<!-- Bulk Operations Forms (hidden) -->
{% if current_user.is_admin %}
<!-- Note: Bulk operations routes need to be implemented in the backend -->
<form id="confirmBulkDelete-form" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusForm" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" id="bulkStatusValue">
</form>
<!-- Bulk Delete Confirmation Dialog -->
<div id="confirmBulkDelete" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="closeBulkDeleteDialog()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">Delete Selected Invoices</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">Are you sure you want to delete the selected invoices? This action cannot be undone.</p>
</div>
</div>
<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="closeBulkDeleteDialog()">
Cancel
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="submitBulkDelete()">
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Status Change Dialog -->
<div id="bulkStatusDialog" 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">Change Status for Selected Invoices</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm 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">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Update Status</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Delete Invoice Modal (Single) -->
<div id="deleteInvoiceModal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
<div class="absolute inset-0 bg-black/50" onclick="hideDeleteModal()"></div>
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl ring-1 ring-border-light/60 dark:ring-border-dark/60">
@@ -99,7 +247,183 @@
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('invoiceListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('invoiceListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('invoiceListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
// Bulk actions for invoices
function toggleAllInvoices() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.invoice-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.invoice-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
}
function closeAllMenus() {
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId) {
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
// Position menu (dropup if not enough space below)
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16) {
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
// Click outside to close
document.addEventListener('click', function(e) {
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#invoicesBulkMenu');
if (!insideTrigger && !insideMenu) {
closeAllMenus();
}
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllMenus();
});
function showBulkDeleteConfirm() {
const count = document.querySelectorAll('.invoice-checkbox:checked').length;
if (count === 0) return false;
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
form.querySelectorAll('input[name="invoice_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.invoice-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'invoice_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
// Clear existing hidden inputs
form.querySelectorAll('input[name="invoice_ids[]"]').forEach(input => input.remove());
// Add selected invoice IDs to form
const checkboxes = document.querySelectorAll('.invoice-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'invoice_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
{% endif %}
function showDeleteModal(invoiceId, invoiceNumber) {
const numberEl = document.getElementById('deleteInvoiceNumber');
const formEl = document.getElementById('deleteInvoiceForm');

View File

@@ -55,8 +55,14 @@
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Mileage</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Mileage</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()" title="{{ _('Toggle Filters') }}">
<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" data-filter-form>
<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="{{ search or '' }}"
@@ -107,7 +113,7 @@
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2">
<div class="flex items-end gap-2 col-span-full md:col-span-2">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
@@ -116,99 +122,283 @@
</a>
</div>
</form>
</div>
</div>
<!-- Mileage Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4 p-6 pb-0">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ mileage_entries|length }} entr{{ 'ies' if mileage_entries|length != 1 else 'y' }} found
</h3>
<div class="flex items-center gap-2">
{% 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, 'mileageBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="mileageBulkMenu" 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>
<div class="overflow-x-auto p-6 pt-4">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Route</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Distance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
{% 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="toggleAllMileage()">
</th>
{% endif %}
<th class="p-4" data-sortable>Date</th>
<th class="p-4" data-sortable>Purpose</th>
<th class="p-4" data-sortable>Route</th>
<th class="p-4 table-number" data-sortable>Distance</th>
<th class="p-4 table-number" data-sortable>Amount</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{% if mileage_entries %}
{% for entry in mileage_entries %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ entry.date.strftime('%Y-%m-%d') if entry.date else (entry.trip_date.strftime('%Y-%m-%d') if entry.trip_date else '-') }}
<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="mileage-entry-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ entry.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="px-6 py-4 text-sm">
{% endif %}
<td class="p-4 whitespace-nowrap">{{ entry.date.strftime('%Y-%m-%d') if entry.date else (entry.trip_date.strftime('%Y-%m-%d') if entry.trip_date else '—') }}</td>
<td class="p-4">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:underline font-medium">
{{ entry.purpose }}
</a>
{% if entry.project %}
<div class="text-xs text-gray-500">{{ entry.project.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ entry.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<td class="p-4">
<div class="flex flex-col">
<span class="text-gray-600">{{ entry.start_location }}</span>
<i class="fas fa-arrow-down text-xs text-gray-400 my-1"></i>
<span class="text-gray-600">{{ entry.end_location }}</span>
<span class="text-gray-600 dark:text-gray-400">{{ entry.start_location }}</span>
<i class="fas fa-arrow-down text-xs text-gray-400 dark:text-gray-500 my-1"></i>
<span class="text-gray-600 dark:text-gray-400">{{ entry.end_location }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(entry.distance_km) }} km
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ entry.currency_code or 'EUR' }} {{ '%.2f'|format(entry.total_amount or (entry.calculated_amount or 0)) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<td class="p-4 table-number font-medium">{{ '%.2f'|format(entry.distance_km) }} km</td>
<td class="p-4 table-number font-medium">{{ entry.currency_code or 'EUR' }} {{ '%.2f'|format(entry.total_amount or (entry.calculated_amount or 0)) }}</td>
<td class="p-4">
{% if entry.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">Pending</span>
{% elif entry.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Approved</span>
{% elif entry.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Rejected</span>
{% elif entry.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Reimbursed</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ entry.status|title }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<a href="{{ url_for('mileage.edit_mileage', mileage_id=entry.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
<td class="p-4">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-car text-4xl mb-2 opacity-50"></i>
<p>No mileage entries found</p>
<a href="{{ url_for('mileage.create_mileage') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first mileage entry
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if not mileage_entries %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('mileage.create_mileage') }}" 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 Mileage Entry
</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 %}
{{ empty_state('fas fa-car', 'No Mileage Entries Found', 'Get started by creating your first mileage entry.', actions) }}
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('mileageListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('mileageListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('mileageListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
// Bulk actions for mileage
function toggleAllMileage() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.mileage-entry-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.mileage-entry-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
}
function closeAllMenus() {
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId) {
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16) {
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
document.addEventListener('click', function(e) {
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#mileageBulkMenu');
if (!insideTrigger && !insideMenu) {
closeAllMenus();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllMenus();
});
function showBulkDeleteConfirm() {
const count = document.querySelectorAll('.mileage-entry-checkbox:checked').length;
if (count === 0) return false;
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
form.querySelectorAll('input[name="mileage_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.mileage-entry-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'mileage_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
form.querySelectorAll('input[name="mileage_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.mileage-entry-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'mileage_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -41,7 +41,14 @@
<!-- Filters -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Payments</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()" title="{{ _('Toggle Filters') }}">
<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-5 gap-4" data-filter-form>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="form-input">
@@ -69,91 +76,290 @@
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">To Date</label>
<input type="date" name="date_to" id="date_to" value="{{ filters.date_to }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg mr-2">Filter</button>
<div class="flex items-end col-span-full justify-end gap-2">
<button type="submit" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg">Filter</button>
<a href="{{ url_for('payments.list_payments') }}" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">Clear</a>
</div>
</form>
</div>
</div>
<!-- Payments Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
{% if payments %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4 p-6 pb-0">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ payments|length }} payment{{ 's' if payments|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('payments.export_payments_excel', status=filters.status, method=filters.method, date_from=filters.date_from, date_to=filters.date_to) }}" 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 Excel">
<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, 'paymentsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="paymentsBulkMenu" 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>
<div class="overflow-x-auto p-6 pt-4">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Invoice</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Method</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
{% 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="toggleAllPayments()">
</th>
{% endif %}
<th class="p-4" data-sortable>ID</th>
<th class="p-4" data-sortable>Invoice</th>
<th class="p-4 table-number" data-sortable>Amount</th>
<th class="p-4" data-sortable>Date</th>
<th class="p-4" data-sortable>Method</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for payment in payments %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">#{{ payment.id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}" class="text-primary hover:text-primary-dark">
{{ payment.invoice.invoice_number }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-semibold text-green-600 dark:text-green-400">
{{ payment.amount }} {{ payment.currency or 'EUR' }}
</span>
{% if payment.gateway_fee %}
<span class="text-xs text-gray-500 dark:text-gray-400 block">
Fee: {{ payment.gateway_fee }} {{ payment.currency or 'EUR' }}
</span>
<tbody>
{% if payments %}
{% for payment in payments %}
<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="payment-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ payment.id }}" onchange="updateBulkDeleteButton()">
</td>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ payment.method or 'N/A' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if payment.status == 'completed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
Completed
</span>
{% elif payment.status == 'pending' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
Pending
</span>
{% elif payment.status == 'failed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">
Failed
</span>
{% elif payment.status == 'refunded' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100">
Refunded
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="text-primary hover:text-primary-dark mr-3">View</a>
<a href="{{ url_for('payments.edit_payment', payment_id=payment.id) }}" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 mr-3">Edit</a>
</td>
</tr>
{% endfor %}
<td class="p-4 font-medium">#{{ payment.id }}</td>
<td class="p-4">
<a href="{{ url_for('invoices.view_invoice', invoice_id=payment.invoice_id) }}" class="text-primary hover:underline">
{{ payment.invoice.invoice_number }}
</a>
</td>
<td class="p-4 table-number">
<span class="font-semibold text-green-600 dark:text-green-400">
{{ payment.amount }} {{ payment.currency or 'EUR' }}
</span>
{% if payment.gateway_fee %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Fee: {{ payment.gateway_fee }} {{ payment.currency or 'EUR' }}
</div>
{% endif %}
</td>
<td class="p-4">{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else '—' }}</td>
<td class="p-4">{{ payment.method or '—' }}</td>
<td class="p-4">
{% if payment.status == 'completed' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">Completed</span>
{% elif payment.status == 'pending' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">Pending</span>
{% elif payment.status == 'failed' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100">Failed</span>
{% elif payment.status == 'refunded' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100">Refunded</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ payment.status|title }}</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('payments.view_payment', payment_id=payment.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
{% if not payments %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('payments.create_payment') }}" 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>Record Your First Payment
</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 %}
{{ empty_state('fas fa-credit-card', 'No Payments Found', 'Get started by recording your first payment.', actions) }}
{% endif %}
</div>
{% else %}
<div class="p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No payments found.</p>
<a href="{{ url_for('payments.create_payment') }}" class="text-primary hover:text-primary-dark mt-2 inline-block">Record your first payment</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('paymentListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('paymentListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('paymentListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
// Bulk actions for payments
function toggleAllPayments() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.payment-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.payment-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
}
function closeAllMenus() {
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId) {
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16) {
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
document.addEventListener('click', function(e) {
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#paymentsBulkMenu');
if (!insideTrigger && !insideMenu) {
closeAllMenus();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllMenus();
});
function showBulkDeleteConfirm() {
const count = document.querySelectorAll('.payment-checkbox:checked').length;
if (count === 0) return false;
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
form.querySelectorAll('input[name="payment_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.payment-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'payment_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
form.querySelectorAll('input[name="payment_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.payment-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'payment_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -43,8 +43,14 @@
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Claims</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Claims</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()" title="{{ _('Toggle Filters') }}">
<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" data-filter-form>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
@@ -78,7 +84,7 @@
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2 lg:col-span-4">
<div class="flex items-end gap-2 col-span-full md:col-span-2 lg:col-span-4">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
@@ -87,96 +93,340 @@
</a>
</div>
</form>
</div>
</div>
{% if current_user.is_admin %}
<!-- Bulk Operations Forms (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusForm" method="POST" action="#" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" id="bulkStatusValue">
</form>
<!-- Bulk Delete Confirmation Dialog -->
<div id="confirmBulkDelete" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="closeBulkDeleteDialog()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">Delete Selected Claims</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">Are you sure you want to delete the selected per diem claims? This action cannot be undone.</p>
</div>
</div>
<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="closeBulkDeleteDialog()">
Cancel
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="submitBulkDelete()">
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Status Change Dialog -->
<div id="bulkStatusDialog" 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">Change Status for Selected Claims</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="reimbursed">Reimbursed</option>
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm 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">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Update Status</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Claims Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4 p-6 pb-0">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ per_diem_claims|length }} claim{{ 's' if per_diem_claims|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
{% 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, 'perDiemBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="perDiemBulkMenu" 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>
<div class="overflow-x-auto p-6 pt-4">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Period</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Days</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
{% 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="toggleAllPerDiem()">
</th>
{% endif %}
<th class="p-4" data-sortable>Period</th>
<th class="p-4" data-sortable>Purpose</th>
<th class="p-4" data-sortable>Location</th>
<th class="p-4 table-number" data-sortable>Days</th>
<th class="p-4 table-number" data-sortable>Amount</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{% if per_diem_claims %}
{% for claim in per_diem_claims %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.start_date.strftime('%Y-%m-%d') }}<br>
<span class="text-xs text-gray-500">to {{ claim.end_date.strftime('%Y-%m-%d') }}</span>
<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="per-diem-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ claim.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="px-6 py-4 text-sm">
{% endif %}
<td class="p-4 whitespace-nowrap">
{{ claim.start_date.strftime('%Y-%m-%d') }}<br>
<span class="text-xs text-gray-500 dark:text-gray-400">to {{ claim.end_date.strftime('%Y-%m-%d') }}</span>
</td>
<td class="p-4">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:underline font-medium">
{{ claim.trip_purpose }}
</a>
{% if claim.project %}
<div class="text-xs text-gray-500">{{ claim.project.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ claim.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{{ claim.city + ', ' if claim.city else '' }}{{ claim.country }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.total_days if claim.total_days else ((claim.full_days or 0) + (claim.half_days or 0) * 0.5) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ claim.currency_code or 'EUR' }} {{ '%.2f'|format(claim.calculated_amount or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<td class="p-4">{{ claim.city + ', ' if claim.city else '' }}{{ claim.country }}</td>
<td class="p-4 table-number">{{ claim.total_days if claim.total_days else ((claim.full_days or 0) + (claim.half_days or 0) * 0.5) }}</td>
<td class="p-4 table-number font-medium">{{ claim.currency_code or 'EUR' }} {{ '%.2f'|format(claim.calculated_amount or 0) }}</td>
<td class="p-4">
{% if claim.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">Pending</span>
{% elif claim.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Approved</span>
{% elif claim.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Rejected</span>
{% elif claim.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Reimbursed</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ claim.status|title }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or claim.user_id == current_user.id %}
<a href="{{ url_for('per_diem.edit_per_diem', per_diem_id=claim.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
<td class="p-4">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-money-bill-alt text-4xl mb-2 opacity-50"></i>
<p>No per diem claims found</p>
<a href="{{ url_for('per_diem.create_per_diem') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first claim
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if not per_diem_claims %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('per_diem.create_per_diem') }}" 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 Claim
</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 %}
{{ empty_state('fas fa-money-bill-alt', 'No Per Diem Claims Found', 'Get started by creating your first per diem claim.', actions) }}
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('perDiemListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('perDiemListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('perDiemListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
{% if current_user.is_admin %}
// Bulk actions for per diem
function toggleAllPerDiem() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.per-diem-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.per-diem-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
}
function closeAllMenus() {
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
}
function openMenu(triggerEl, menuId) {
const menu = document.getElementById(menuId);
if (!menu) return;
const willOpen = menu.classList.contains('hidden');
closeAllMenus();
if (!willOpen) return;
menu.style.top = '';
menu.style.bottom = '';
const rect = triggerEl.getBoundingClientRect();
const menuHeight = menu.offsetHeight || 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16) {
menu.style.bottom = 'calc(100% + 8px)';
} else {
menu.style.top = 'calc(100% + 8px)';
}
menu.classList.remove('hidden');
}
document.addEventListener('click', function(e) {
const insideTrigger = e.target.closest('#bulkActionsBtn');
const insideMenu = e.target.closest('#perDiemBulkMenu');
if (!insideTrigger && !insideMenu) {
closeAllMenus();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllMenus();
});
function showBulkDeleteConfirm() {
const count = document.querySelectorAll('.per-diem-checkbox:checked').length;
if (count === 0) return false;
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
form.querySelectorAll('input[name="per_diem_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.per-diem-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'per_diem_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
form.querySelectorAll('input[name="per_diem_ids[]"]').forEach(input => input.remove());
const checkboxes = document.querySelectorAll('.per-diem-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'per_diem_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -15,7 +15,13 @@
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Projects</h2>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Projects</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()" title="{{ _('Toggle Filters') }}">
<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-5 gap-4" data-filter-form>
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
@@ -46,10 +52,11 @@
<option value="true" {% if favorites_only %}selected{% endif %}>⭐ Favorites Only</option>
</select>
</div>
<div class="self-end">
<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">
@@ -66,7 +73,7 @@
<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 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg max-h-64 overflow-y-auto">
<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>
@@ -77,11 +84,11 @@
{% endif %}
</div>
</div>
<table class="w-full text-left">
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
{% if current_user.is_admin %}
<th class="p-4 w-10">
<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 %}
@@ -97,7 +104,7 @@
</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/50 transition-colors">
<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()">
@@ -118,18 +125,18 @@
<td class="p-4">{{ project.client }}</td>
<td class="p-4">
{% if project.status == 'active' %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
<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 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Inactive</span>
<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 bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Archived</span>
<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 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Billable</span>
<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 bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">Nonbillable</span>
<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">
@@ -151,7 +158,7 @@
{% 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 {{ badge_classes }}">{{ pct|round(0) }}%</span>
<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 %}
@@ -182,6 +189,10 @@
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('projects.bulk_status_change') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
@@ -191,6 +202,53 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<script>
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const isCollapsed = filterBody.classList.contains('filter-collapsed');
if (isCollapsed) {
// Show filters
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('projectListFiltersVisible', 'true');
} else {
// Hide filters
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('projectListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('projectListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
toggleButton.title = '{{ _('Show Filters') }}';
} else {
// Explicitly set the icon to chevron-up when filter is visible
filterBody.classList.remove('filter-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
});
function toggleAllProjects(){
const selectAll = document.getElementById('selectAll');
document.querySelectorAll('.project-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));