coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');
}
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, '&quot;') : ''}">`;
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;