Add sortable transaction table with row detail modal

- Add custom column sorting (click headers to sort, click again to reverse)
- Add row click to open detail modal showing all transaction fields
- Row detail modal uses table layout with labels and values side by side
- Always show scrollbars, reset scroll position when modal opens
- Global escape key handler closes detail modal first, then main modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-30 19:31:18 +03:00
parent bd6b73b389
commit 08db280f84
3 changed files with 268 additions and 32 deletions

183
app.js
View File

@ -1770,12 +1770,16 @@ const csvColumnLabels = {
// Preferred column order (most important first)
const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment'];
// Modal state
let currentTransactions = [];
let currentColumns = [];
let currentSortColumn = null;
let currentSortDirection = 'desc';
// Open transaction modal
function openTransactionModal(item) {
const modal = document.getElementById('transaction-modal');
const modalTitle = document.getElementById('modal-title');
const modalThead = document.getElementById('modal-thead');
const modalTbody = document.getElementById('modal-tbody');
// Set title
modalTitle.textContent = item.name;
@ -1788,89 +1792,208 @@ function openTransactionModal(item) {
transactions = [item];
}
// Clear previous content
modalThead.innerHTML = '';
modalTbody.innerHTML = '';
if (transactions.length === 0) {
const modalTbody = document.getElementById('modal-tbody');
const modalThead = document.getElementById('modal-thead');
modalThead.innerHTML = '';
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
modal.style.display = 'flex';
setupModalListeners();
return;
}
// Sort by date descending
transactions.sort((a, b) => {
const dateA = a.originalRow?.transaction_date || a.date || '';
const dateB = b.originalRow?.transaction_date || b.date || '';
return dateB.localeCompare(dateA);
// Get columns from first transaction
const sampleRow = transactions[0].originalRow;
currentColumns = csvColumnOrder.filter(col => col in sampleRow);
currentTransactions = transactions;
// Initial sort by amount descending
currentSortColumn = 'amount_rub';
currentSortDirection = 'desc';
renderTransactionTable();
modal.style.display = 'flex';
// Reset scroll position to top-left
const tableContainer = document.querySelector('.modal-table-container');
tableContainer.scrollTop = 0;
tableContainer.scrollLeft = 0;
setupModalListeners();
}
// Render the transaction table with current sort
function renderTransactionTable() {
const modalThead = document.getElementById('modal-thead');
const modalTbody = document.getElementById('modal-tbody');
// Sort transactions
const sortedTransactions = [...currentTransactions].sort((a, b) => {
const aVal = a.originalRow?.[currentSortColumn] ?? '';
const bVal = b.originalRow?.[currentSortColumn] ?? '';
// Numeric sort for amount columns
if (currentSortColumn === 'amount_rub' || currentSortColumn === 'amount_original') {
const aNum = parseFloat(aVal) || 0;
const bNum = parseFloat(bVal) || 0;
return currentSortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
// String sort for other columns
const aStr = String(aVal);
const bStr = String(bVal);
const cmp = aStr.localeCompare(bStr);
return currentSortDirection === 'asc' ? cmp : -cmp;
});
// Get all columns from the first transaction's originalRow
const sampleRow = transactions[0].originalRow;
const availableColumns = csvColumnOrder.filter(col => col in sampleRow);
// Clear content
modalThead.innerHTML = '';
modalTbody.innerHTML = '';
// Build header
// Build header with sort indicators
const headerRow = document.createElement('tr');
availableColumns.forEach(col => {
currentColumns.forEach(col => {
const th = document.createElement('th');
th.textContent = csvColumnLabels[col] || col;
th.dataset.column = col;
// Add sort class
if (col === currentSortColumn) {
th.classList.add(currentSortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
}
// Click to sort
th.addEventListener('click', () => {
if (currentSortColumn === col) {
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
} else {
currentSortColumn = col;
currentSortDirection = 'asc';
}
renderTransactionTable();
});
headerRow.appendChild(th);
});
modalThead.appendChild(headerRow);
// Build rows
transactions.forEach(transaction => {
sortedTransactions.forEach(transaction => {
const row = document.createElement('tr');
availableColumns.forEach(col => {
currentColumns.forEach(col => {
const td = document.createElement('td');
let value = transaction.originalRow[col];
// Format amount values
if ((col === 'amount' || col === 'original_amount') && typeof value === 'number') {
if ((col === 'amount_rub' || col === 'amount_original') && typeof value === 'number') {
value = value.toLocaleString();
}
td.textContent = value !== undefined && value !== null ? value : '';
td.title = td.textContent; // Show full text on hover
td.title = td.textContent;
row.appendChild(td);
});
// Row click opens detail modal
row.addEventListener('click', () => openRowDetailModal(transaction));
modalTbody.appendChild(row);
});
}
// Open the row detail modal
function openRowDetailModal(transaction) {
const modal = document.getElementById('row-detail-modal');
const title = document.getElementById('row-detail-title');
const body = document.getElementById('row-detail-body');
const name = transaction.originalRow?.simple_name || transaction.name || 'Transaction';
title.textContent = name;
body.innerHTML = '';
currentColumns.forEach(col => {
// Skip simple_name since it's already shown in the title
if (col === 'simple_name') return;
const value = transaction.originalRow[col];
const itemDiv = document.createElement('div');
itemDiv.className = 'row-detail-item';
const labelSpan = document.createElement('span');
labelSpan.className = 'row-detail-label';
labelSpan.textContent = csvColumnLabels[col] || col;
const valueSpan = document.createElement('span');
valueSpan.className = 'row-detail-value';
valueSpan.textContent = value;
itemDiv.appendChild(labelSpan);
itemDiv.appendChild(valueSpan);
body.appendChild(itemDiv);
});
modal.style.display = 'flex';
setupModalListeners();
setupRowDetailModalListeners();
}
// Close row detail modal
function closeRowDetailModal() {
const modal = document.getElementById('row-detail-modal');
modal.style.display = 'none';
}
// Setup row detail modal listeners
function setupRowDetailModalListeners() {
const modal = document.getElementById('row-detail-modal');
const closeBtn = document.getElementById('row-detail-close');
closeBtn.onclick = (e) => {
e.stopPropagation();
closeRowDetailModal();
};
modal.onclick = function(e) {
if (e.target === modal) {
closeRowDetailModal();
}
};
}
// Close transaction modal
function closeTransactionModal() {
const modal = document.getElementById('transaction-modal');
modal.style.display = 'none';
document.removeEventListener('keydown', handleModalEscape);
}
// Handle Escape key to close modal
function handleModalEscape(e) {
// Global escape key handler for all modals
function handleGlobalEscape(e) {
if (e.key === 'Escape') {
const rowDetailModal = document.getElementById('row-detail-modal');
const transactionModal = document.getElementById('transaction-modal');
// Close row detail first if open, then transaction modal
if (rowDetailModal.style.display !== 'none') {
closeRowDetailModal();
} else if (transactionModal.style.display !== 'none') {
closeTransactionModal();
}
}
}
// Setup global escape listener once
document.addEventListener('keydown', handleGlobalEscape);
// Setup modal close listeners
function setupModalListeners() {
const modal = document.getElementById('transaction-modal');
const closeBtn = document.getElementById('modal-close');
// Close on X button click
closeBtn.onclick = closeTransactionModal;
// Close on backdrop click
modal.onclick = function(e) {
if (e.target === modal) {
closeTransactionModal();
}
};
// Close on Escape key
document.addEventListener('keydown', handleModalEscape);
}

View File

@ -54,6 +54,13 @@
</div>
</div>
</div>
<div id="row-detail-modal" class="modal-overlay" style="display: none;">
<div class="row-detail-content">
<button class="modal-close" id="row-detail-close">&times;</button>
<h3 id="row-detail-title">Transaction Details</h3>
<div id="row-detail-body"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@ -440,7 +440,7 @@ body {
.modal-table-container {
flex: 1;
overflow: auto;
overflow: scroll;
padding: 0;
}
@ -496,3 +496,109 @@ body {
}
}
/* Row detail modal styles */
.row-detail-content {
position: relative;
width: 90vw;
max-width: 500px;
max-height: 80vh;
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
padding: 24px;
overflow-y: auto;
}
.row-detail-content .modal-close {
position: absolute;
top: 12px;
right: 12px;
}
.row-detail-content h3 {
margin: 0 0 20px 0;
font-size: 18px;
color: #333;
padding-right: 30px;
}
#row-detail-body {
display: table;
width: 100%;
border-collapse: collapse;
}
.row-detail-item {
display: table-row;
}
.row-detail-item:hover {
background-color: #f8f9fa;
}
.row-detail-label {
display: table-cell;
padding: 8px 32px 8px 0;
font-size: 13px;
color: #999;
font-weight: 400;
white-space: nowrap;
vertical-align: top;
border-bottom: 1px solid #eee;
min-width: 140px;
}
.row-detail-value {
display: table-cell;
padding: 8px 0;
font-size: 13px;
color: #222;
font-weight: 600;
word-break: break-word;
border-bottom: 1px solid #eee;
}
.row-detail-item:last-child .row-detail-label,
.row-detail-item:last-child .row-detail-value {
border-bottom: none;
}
/* Sortable table header styles */
.transaction-table th {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 20px !important;
}
.transaction-table th:hover {
background-color: #f0f0f0;
}
.transaction-table th::after {
content: '';
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
opacity: 0.3;
}
.transaction-table th.sort-asc::after {
border-bottom-color: #333;
border-top: none;
opacity: 1;
}
.transaction-table th.sort-desc::after {
border-top-color: #333;
border-bottom: none;
opacity: 1;
}
.transaction-table tbody tr {
cursor: pointer;
transition: background-color 0.15s;
}