coming together
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
{% block title %}Financial/Ledger - Delphi Database{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -61,9 +61,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Recent Time Entries -->
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div class="flex justify-between items-center p-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-lg font-semibold"><i class="fa-solid fa-clock-rotate-left mr-2"></i>Recent Time Entries</h2>
|
||||
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
|
||||
<div class="flex justify-between items-center px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-clock-rotate-left"></i><span>Recent Time Entries</span></h2>
|
||||
<div class="flex gap-2">
|
||||
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="recentDaysFilter">
|
||||
<option value="7">Last 7 days</option>
|
||||
@@ -73,25 +73,31 @@
|
||||
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="employeeFilter">
|
||||
<option value="">All Employees</option>
|
||||
</select>
|
||||
<button class="px-3 py-1 bg-gray-200 dark:bg-neutral-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
|
||||
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="statusFilter">
|
||||
<option value="">All Status</option>
|
||||
<option value="billed">Billed</option>
|
||||
<option value="unbilled">Unbilled</option>
|
||||
</select>
|
||||
<input type="search" id="descSearch" placeholder="Search description..." class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200 w-48" />
|
||||
<button class="px-3 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="bg-neutral-100 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th class="px-4 py-2">Date</th>
|
||||
<th class="px-4 py-2">File</th>
|
||||
<th class="px-4 py-2">Client</th>
|
||||
<th class="px-4 py-2">Employee</th>
|
||||
<th class="px-4 py-2">Hours</th>
|
||||
<th class="px-4 py-2">Rate</th>
|
||||
<th class="px-4 py-2">Amount</th>
|
||||
<th class="px-4 py-2">Description</th>
|
||||
<th class="px-4 py-2">Status</th>
|
||||
<th class="px-4 py-2">Actions</th>
|
||||
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="recentEntriesTable">
|
||||
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Date</th>
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Employee</th>
|
||||
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Hours</th>
|
||||
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Rate</th>
|
||||
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount</th>
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentEntriesTableBody">
|
||||
@@ -99,6 +105,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-6 py-3 border-t border-neutral-200 dark:border-neutral-700 text-sm" id="recentPagination" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<!-- Action Cards Row -->
|
||||
@@ -125,17 +132,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md">
|
||||
<div class="p-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-lg font-semibold"><i class="fa-solid fa-chart-column mr-2"></i>Top Files by Balance</h2>
|
||||
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
|
||||
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-chart-column"></i><span>Top Files by Balance</span></h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="bg-neutral-100 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th class="px-4 py-2">File</th>
|
||||
<th class="px-4 py-2">Total Charges</th>
|
||||
<th class="px-4 py-2">Amount Owing</th>
|
||||
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="topFilesTable">
|
||||
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
|
||||
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Total Charges</th>
|
||||
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount Owing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="topFilesTableBody">
|
||||
@@ -482,6 +489,7 @@
|
||||
let dashboardData = null;
|
||||
let recentEntries = [];
|
||||
let unbilledData = null;
|
||||
let recentEditSnapshots = {};
|
||||
|
||||
// Authorization and JSON headers are injected by window.http.wrappedFetch
|
||||
|
||||
@@ -509,10 +517,54 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
function initializeFinancialPage() {
|
||||
// Initialize any data tables or components
|
||||
// Initialize sortable tables using shared helper
|
||||
try { initializeDataTable('recentEntriesTable'); } catch (_) {}
|
||||
try { initializeDataTable('topFilesTable'); } catch (_) {}
|
||||
// Persisted filters restore
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('financial_recent_filters') || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
if (saved.days && document.getElementById('recentDaysFilter')) document.getElementById('recentDaysFilter').value = String(saved.days);
|
||||
if (typeof saved.employee === 'string' && document.getElementById('employeeFilter')) document.getElementById('employeeFilter').value = saved.employee;
|
||||
if (typeof saved.status === 'string' && document.getElementById('statusFilter')) document.getElementById('statusFilter').value = saved.status;
|
||||
if (typeof saved.query === 'string' && document.getElementById('descSearch')) document.getElementById('descSearch').value = saved.query;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Persist sort state on header clicks
|
||||
attachSortPersistence('recentEntriesTable', 'financial_recent_sort');
|
||||
attachSortPersistence('topFilesTable', 'financial_top_sort');
|
||||
console.log('Financial page initialized');
|
||||
}
|
||||
|
||||
// Highlight helpers
|
||||
function _finEscapeHtml(text) {
|
||||
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
|
||||
const str = String(text == null ? '' : text);
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function _finBuildTokens(raw) {
|
||||
return String(raw || '')
|
||||
.trim()
|
||||
.replace(/[,_;:]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
function finHighlightText(text, tokens) {
|
||||
if (!text) return '';
|
||||
const unique = Array.from(new Set(tokens || []));
|
||||
if (unique.length === 0) return _finEscapeHtml(text);
|
||||
let safe = _finEscapeHtml(String(text));
|
||||
try {
|
||||
unique.forEach(tok => {
|
||||
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${esc})`, 'ig');
|
||||
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
|
||||
});
|
||||
} catch (_) {}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Quick actions
|
||||
document.getElementById('quickTimeBtn').addEventListener('click', showQuickTimeModal);
|
||||
@@ -530,9 +582,28 @@ function setupEventListeners() {
|
||||
document.getElementById('billSelectedBtn').addEventListener('click', billSelectedEntries);
|
||||
|
||||
// Filters
|
||||
document.getElementById('recentDaysFilter').addEventListener('change', loadRecentTimeEntries);
|
||||
document.getElementById('employeeFilter').addEventListener('change', loadRecentTimeEntries);
|
||||
document.getElementById('recentDaysFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
|
||||
document.getElementById('employeeFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
|
||||
document.getElementById('refreshRecentBtn').addEventListener('click', loadRecentTimeEntries);
|
||||
const statusFilterEl = document.getElementById('statusFilter');
|
||||
if (statusFilterEl) statusFilterEl.addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
|
||||
const descSearchEl = document.getElementById('descSearch');
|
||||
if (descSearchEl) descSearchEl.addEventListener('input', (typeof debounce === 'function' ? debounce(() => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }, 300) : () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }));
|
||||
// Persist filters on change
|
||||
const persistFilters = () => {
|
||||
const payload = {
|
||||
days: parseInt(document.getElementById('recentDaysFilter')?.value || '7', 10),
|
||||
employee: document.getElementById('employeeFilter')?.value || '',
|
||||
status: document.getElementById('statusFilter')?.value || '',
|
||||
query: document.getElementById('descSearch')?.value || ''
|
||||
};
|
||||
try { localStorage.setItem('financial_recent_filters', JSON.stringify(payload)); } catch (_) {}
|
||||
};
|
||||
['recentDaysFilter','employeeFilter','statusFilter','descSearch'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener(id === 'descSearch' ? 'input' : 'change', persistFilters);
|
||||
});
|
||||
|
||||
// File selection buttons
|
||||
document.getElementById('selectFileBtn').addEventListener('click', () => showFileSelector('quickTimeFile'));
|
||||
@@ -580,33 +651,57 @@ function updateDashboardSummary(data) {
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Reapply previous sort state if any
|
||||
reapplyTableSort('topFilesTable');
|
||||
}
|
||||
|
||||
async function loadRecentTimeEntries() {
|
||||
const days = document.getElementById('recentDaysFilter').value;
|
||||
const employee = document.getElementById('employeeFilter').value;
|
||||
const status = (document.getElementById('statusFilter')?.value || '').trim();
|
||||
const q = (document.getElementById('descSearch')?.value || '').trim();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ days });
|
||||
const { page, limit, sort_by, sort_dir } = getRecentServerState();
|
||||
const params = new URLSearchParams({ days, page, limit, sort_by, sort_dir });
|
||||
if (employee) params.append('employee', employee);
|
||||
|
||||
if (status) params.append('status', status);
|
||||
if (q) params.append('q', q);
|
||||
const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load recent entries');
|
||||
|
||||
const data = await response.json();
|
||||
recentEntries = data.entries;
|
||||
displayRecentTimeEntries(data.entries);
|
||||
|
||||
renderRecentPagination(data.total_count, data.page, data.limit);
|
||||
refreshRecentEntriesView();
|
||||
} catch (error) {
|
||||
console.error('Error loading recent entries:', error);
|
||||
showAlert('Error loading recent time entries: ' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRecentEntriesView() {
|
||||
displayRecentTimeEntries(recentEntries || []);
|
||||
}
|
||||
|
||||
function applyRecentEntriesFilters(entries) {
|
||||
const status = (document.getElementById('statusFilter')?.value || '').toLowerCase();
|
||||
const query = (document.getElementById('descSearch')?.value || '').trim().toLowerCase();
|
||||
if (!entries || entries.length === 0) return [];
|
||||
return entries.filter(e => {
|
||||
let statusOk = true;
|
||||
if (status === 'billed') statusOk = !!e.billed;
|
||||
else if (status === 'unbilled') statusOk = !e.billed;
|
||||
const text = [e.description, e.client_name, e.file_no, e.employee].filter(Boolean).join(' ').toLowerCase();
|
||||
const queryOk = query ? text.includes(query) : true;
|
||||
return statusOk && queryOk;
|
||||
});
|
||||
}
|
||||
|
||||
function displayRecentTimeEntries(entries) {
|
||||
const tbody = document.getElementById('recentEntriesTableBody');
|
||||
tbody.innerHTML = '';
|
||||
const tokens = _finBuildTokens(document.getElementById('descSearch') ? document.getElementById('descSearch').value : '');
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-neutral-500">No recent time entries found</td></tr>';
|
||||
@@ -615,28 +710,128 @@ function displayRecentTimeEntries(entries) {
|
||||
|
||||
entries.forEach(entry => {
|
||||
const row = document.createElement('tr');
|
||||
row.setAttribute('data-entry-id', String(entry.id));
|
||||
row.innerHTML = `
|
||||
<td>${formatDate(entry.date)}</td>
|
||||
<td><strong>${entry.file_no}</strong></td>
|
||||
<td>${entry.client_name}</td>
|
||||
<td>${entry.employee}</td>
|
||||
<td><strong>${finHighlightText(entry.file_no, tokens)}</strong></td>
|
||||
<td>${finHighlightText(entry.client_name, tokens)}</td>
|
||||
<td>${finHighlightText(entry.employee, tokens)}</td>
|
||||
<td class="text-center">${entry.hours}</td>
|
||||
<td class="text-right">${formatCurrency(entry.rate)}</td>
|
||||
<td class="text-right text-green-600"><strong>${formatCurrency(entry.amount)}</strong></td>
|
||||
<td class="small">${entry.description ? entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : '') : ''}</td>
|
||||
<td class="small">${entry.description ? finHighlightText(entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : ''), tokens) : ''}</td>
|
||||
<td>
|
||||
<span class="inline-block px-2 py-0.5 text-xs rounded ${entry.billed ? 'bg-green-100 text-green-700 border border-green-400' : 'bg-yellow-100 text-yellow-700 border border-yellow-500'}">
|
||||
${entry.billed ? 'Billed' : 'Unbilled'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})">
|
||||
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})" title="Edit">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</button>
|
||||
${entry.billed ? '' : `
|
||||
<button class="ml-1 px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-danger-50" onclick="deleteTimeEntry(${entry.id})" title="Delete">
|
||||
<i class="fa-regular fa-trash-can"></i>
|
||||
</button>
|
||||
`}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Reapply previous sort state if any
|
||||
reapplyTableSort('recentEntriesTable');
|
||||
}
|
||||
|
||||
// Preserve and reapply table sort state across re-renders
|
||||
function reapplyTableSort(tableId) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
const headerAsc = table.querySelector('th.sort-asc');
|
||||
const headerDesc = table.querySelector('th.sort-desc');
|
||||
const header = headerAsc || headerDesc;
|
||||
if (header) {
|
||||
// The shared sortTable determines direction based on presence of 'sort-asc' BEFORE it removes classes
|
||||
if (headerAsc) header.classList.remove('sort-asc');
|
||||
else if (headerDesc) header.classList.add('sort-asc');
|
||||
try { sortTable(table, header); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
// No current sort in DOM; attempt to restore persisted sort
|
||||
const storageKey = tableId === 'recentEntriesTable' ? 'financial_recent_sort' : (tableId === 'topFilesTable' ? 'financial_top_sort' : null);
|
||||
if (!storageKey) return;
|
||||
let spec = null;
|
||||
try { spec = JSON.parse(localStorage.getItem(storageKey) || 'null'); } catch (_) { spec = null; }
|
||||
if (!spec || typeof spec.columnIndex !== 'number' || !spec.direction) return;
|
||||
const headerRow = table.querySelector('thead tr');
|
||||
if (!headerRow) return;
|
||||
const th = headerRow.children[spec.columnIndex];
|
||||
if (!th) return;
|
||||
if (String(spec.direction).toLowerCase() === 'asc') th.classList.remove('sort-asc');
|
||||
else th.classList.add('sort-asc');
|
||||
try { sortTable(table, th); } catch (_) {}
|
||||
}
|
||||
|
||||
function attachSortPersistence(tableId, storageKey) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
const headers = table.querySelectorAll('th[data-sort]');
|
||||
headers.forEach((header, idx) => {
|
||||
header.addEventListener('click', () => {
|
||||
// Determine target server sort field for recent table
|
||||
if (tableId === 'recentEntriesTable') {
|
||||
const fieldMap = ['date','file_no','client_name','empl_num','hours','rate','amount','description','billed'];
|
||||
const sortBy = fieldMap[idx] || 'date';
|
||||
// Toggle direction based on current persisted direction
|
||||
const current = getRecentServerState();
|
||||
const nextDir = (current.sort_by === sortBy) ? (current.sort_dir === 'asc' ? 'desc' : 'asc') : 'asc';
|
||||
setRecentServerState({ sort_by: sortBy, sort_dir: nextDir, page: 1 });
|
||||
loadRecentTimeEntries();
|
||||
}
|
||||
// Persist client-side header state for visual cues
|
||||
setTimeout(() => {
|
||||
const isAsc = header.classList.contains('sort-asc');
|
||||
const direction = isAsc ? 'asc' : (header.classList.contains('sort-desc') ? 'desc' : 'asc');
|
||||
const payload = { columnIndex: idx, direction };
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(payload)); } catch (_) {}
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getRecentServerState() {
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem('financial_recent_server') || 'null'); } catch (_) { saved = null; }
|
||||
const defaults = { page: 1, limit: 50, sort_by: 'date', sort_dir: 'desc' };
|
||||
return Object.assign({}, defaults, saved || {});
|
||||
}
|
||||
|
||||
function setRecentServerState(partial) {
|
||||
const current = getRecentServerState();
|
||||
const next = Object.assign({}, current, partial || {});
|
||||
try { localStorage.setItem('financial_recent_server', JSON.stringify(next)); } catch (_) {}
|
||||
return next;
|
||||
}
|
||||
|
||||
function renderRecentPagination(totalCount, page, limit) {
|
||||
const container = document.getElementById('recentPagination');
|
||||
if (!container) return;
|
||||
const totalPages = Math.max(1, Math.ceil((totalCount || 0) / (limit || 50)));
|
||||
const canPrev = page > 1;
|
||||
const canNext = page < totalPages;
|
||||
container.innerHTML = `
|
||||
<div>
|
||||
Showing page ${page} of ${totalPages} (${totalCount || 0} entries)
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<button class="px-3 py-1 border rounded ${canPrev ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canPrev ? '' : 'disabled'} id="recentPrevPage">Prev</button>
|
||||
<button class="px-3 py-1 border rounded ${canNext ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canNext ? '' : 'disabled'} id="recentNextPage">Next</button>
|
||||
</div>
|
||||
`;
|
||||
const prevBtn = document.getElementById('recentPrevPage');
|
||||
const nextBtn = document.getElementById('recentNextPage');
|
||||
if (prevBtn) prevBtn.addEventListener('click', () => { setRecentServerState({ page: page - 1 }); loadRecentTimeEntries(); });
|
||||
if (nextBtn) nextBtn.addEventListener('click', () => { setRecentServerState({ page: page + 1 }); loadRecentTimeEntries(); });
|
||||
}
|
||||
|
||||
async function loadEmployeeOptions() {
|
||||
@@ -854,6 +1049,7 @@ function displayUnbilledItems(data) {
|
||||
|
||||
const container = document.getElementById('unbilledItemsContainer');
|
||||
container.innerHTML = '';
|
||||
const tokens = _finBuildTokens(document.getElementById('unbilledFileFilter') ? document.getElementById('unbilledFileFilter').value : '');
|
||||
|
||||
if (data.files.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-neutral-500 p-4">No unbilled entries found</div>';
|
||||
@@ -866,8 +1062,8 @@ function displayUnbilledItems(data) {
|
||||
fileCard.innerHTML = `
|
||||
<div class="px-4 py-3 flex justify-between items-center border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div>
|
||||
<strong>${file.file_no}</strong> - ${file.client_name}
|
||||
<br><small class="text-neutral-500">${file.matter}</small>
|
||||
<strong>${finHighlightText(file.file_no, tokens)}</strong> - ${finHighlightText(file.client_name, tokens)}
|
||||
<br><small class="text-neutral-500">${finHighlightText(file.matter, tokens)}</small>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div><strong>Total: ${formatCurrency(file.total_amount)}</strong></div>
|
||||
@@ -900,12 +1096,12 @@ function displayUnbilledItems(data) {
|
||||
data-entry-id="${entry.id}" data-file="${file.file_no}">
|
||||
</td>
|
||||
<td>${formatDate(entry.date)}</td>
|
||||
<td>${entry.type}</td>
|
||||
<td>${entry.employee}</td>
|
||||
<td>${finHighlightText(entry.type, tokens)}</td>
|
||||
<td>${finHighlightText(entry.employee, tokens)}</td>
|
||||
<td class="text-center">${entry.quantity}</td>
|
||||
<td class="text-right">${formatCurrency(entry.rate)}</td>
|
||||
<td class="text-right text-green-600">${formatCurrency(entry.amount)}</td>
|
||||
<td class="small">${entry.description || ''}</td>
|
||||
<td class="small">${entry.description ? finHighlightText(entry.description, tokens) : ''}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
@@ -1057,6 +1253,136 @@ function showFileSelector(targetInputId) {
|
||||
document.getElementById(targetInputId).focus();
|
||||
}
|
||||
|
||||
// Inline edit for recent time entries
|
||||
function editTimeEntry(entryId) {
|
||||
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
|
||||
if (!row) return;
|
||||
if (row.dataset.editing === 'true') {
|
||||
saveEditedEntry(entryId, row);
|
||||
return;
|
||||
}
|
||||
const entry = (recentEntries || []).find(e => e.id === entryId);
|
||||
if (!entry) return;
|
||||
if (entry.billed) {
|
||||
showAlert('This entry is billed and cannot be edited.', 'warning');
|
||||
return;
|
||||
}
|
||||
// Snapshot for rollback
|
||||
recentEditSnapshots[entryId] = {
|
||||
entry: { ...entry },
|
||||
rowHTML: row.innerHTML
|
||||
};
|
||||
row.dataset.editing = 'true';
|
||||
const hoursCell = row.children[4];
|
||||
const rateCell = row.children[5];
|
||||
const amountCell = row.children[6];
|
||||
const descCell = row.children[7];
|
||||
const actionsCell = row.children[9];
|
||||
const currentHours = Number(entry.hours) || 0;
|
||||
const rateValue = Number(entry.rate) || 0;
|
||||
hoursCell.innerHTML = `<input type="number" class="w-20 px-2 py-1 border rounded" step="0.25" min="0" value="${currentHours}">`;
|
||||
descCell.innerHTML = `<input type="text" class="w-full px-2 py-1 border rounded" value="${entry.description ? String(entry.description).replace(/"/g, '"') : ''}">`;
|
||||
const hoursInput = hoursCell.querySelector('input');
|
||||
hoursInput.addEventListener('input', () => {
|
||||
const h = parseFloat(hoursInput.value) || 0;
|
||||
const amt = h * rateValue;
|
||||
amountCell.innerHTML = `<strong>${formatCurrency(amt)}</strong>`;
|
||||
});
|
||||
actionsCell.innerHTML = `
|
||||
<button class="px-2 py-1 border border-green-600 text-green-700 rounded hover:bg-green-100 mr-1" onclick="editTimeEntry(${entryId})" title="Save">
|
||||
<i class="fa-regular fa-circle-check"></i>
|
||||
</button>
|
||||
<button class="px-2 py-1 border border-neutral-500 text-neutral-700 rounded hover:bg-neutral-100" onclick="cancelEditEntry(${entryId})" title="Cancel">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveEditedEntry(entryId, rowEl) {
|
||||
const row = rowEl || document.querySelector(`tr[data-entry-id="${entryId}"]`);
|
||||
if (!row) return;
|
||||
const hoursInput = row.children[4].querySelector('input');
|
||||
const descInput = row.children[7].querySelector('input');
|
||||
const rateText = row.children[5].textContent || '';
|
||||
const rate = parseFloat((rateText.replace(/[^0-9.\-]/g, ''))) || 0;
|
||||
const newHours = parseFloat(hoursInput && hoursInput.value ? hoursInput.value : '0') || 0;
|
||||
const newDesc = descInput ? descInput.value : '';
|
||||
const newAmount = newHours * rate;
|
||||
const payload = { quantity: newHours, amount: newAmount, note: newDesc };
|
||||
const entryIndex = (recentEntries || []).findIndex(e => e.id === entryId);
|
||||
if (entryIndex === -1) return;
|
||||
// Optimistic UI update
|
||||
const snapshot = recentEditSnapshots[entryId];
|
||||
recentEntries[entryIndex] = { ...recentEntries[entryIndex], hours: newHours, amount: newAmount, description: newDesc };
|
||||
// Render non-editing cells immediately
|
||||
row.children[4].innerHTML = `${newHours}`;
|
||||
row.children[6].innerHTML = `<strong>${formatCurrency(newAmount)}</strong>`;
|
||||
row.children[7].innerHTML = `${newDesc ? newDesc.substring(0, 50) + (newDesc.length > 50 ? '...' : '') : ''}`;
|
||||
row.children[9].innerHTML = `
|
||||
<button class=\"px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100\" onclick=\"editTimeEntry(${entryId})\" title=\"Edit\">\n <i class=\"fa-solid fa-pencil\"></i>\n </button>
|
||||
`;
|
||||
delete row.dataset.editing;
|
||||
try {
|
||||
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: 'Update failed' }));
|
||||
throw new Error(err.detail || 'Update failed');
|
||||
}
|
||||
showAlert('Entry updated successfully', 'success');
|
||||
loadDashboardData();
|
||||
// Resort if needed
|
||||
reapplyTableSort('recentEntriesTable');
|
||||
} catch (error) {
|
||||
// Rollback
|
||||
if (snapshot && snapshot.entry) {
|
||||
recentEntries[entryIndex] = { ...snapshot.entry };
|
||||
}
|
||||
refreshRecentEntriesView();
|
||||
showAlert('Failed to update entry: ' + error.message, 'danger');
|
||||
} finally {
|
||||
delete recentEditSnapshots[entryId];
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditEntry(entryId) {
|
||||
// Simply re-render current filtered view
|
||||
delete recentEditSnapshots[entryId];
|
||||
refreshRecentEntriesView();
|
||||
}
|
||||
|
||||
async function deleteTimeEntry(entryId) {
|
||||
const idx = (recentEntries || []).findIndex(e => e.id === entryId);
|
||||
if (idx === -1) return;
|
||||
const entry = recentEntries[idx];
|
||||
if (entry.billed) {
|
||||
showAlert('This entry is billed and cannot be deleted.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm('Delete this time entry? This cannot be undone.');
|
||||
if (!ok) return;
|
||||
const backup = { index: idx, entry: { ...entry } };
|
||||
recentEntries.splice(idx, 1);
|
||||
refreshRecentEntriesView();
|
||||
try {
|
||||
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: 'Delete failed' }));
|
||||
throw new Error(err.detail || 'Delete failed');
|
||||
}
|
||||
showAlert('Entry deleted', 'success');
|
||||
loadDashboardData();
|
||||
} catch (error) {
|
||||
// rollback
|
||||
const restoreIndex = Math.min(backup.index, recentEntries.length);
|
||||
recentEntries.splice(restoreIndex, 0, backup.entry);
|
||||
refreshRecentEntriesView();
|
||||
showAlert('Failed to delete entry: ' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateQuickTimeFile() {
|
||||
const fileNo = document.getElementById('quickTimeFile').value;
|
||||
if (!fileNo) return;
|
||||
|
||||
Reference in New Issue
Block a user