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:
parent
bd6b73b389
commit
08db280f84
185
app.js
185
app.js
@ -1770,12 +1770,16 @@ const csvColumnLabels = {
|
|||||||
// Preferred column order (most important first)
|
// 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'];
|
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
|
// Open transaction modal
|
||||||
function openTransactionModal(item) {
|
function openTransactionModal(item) {
|
||||||
const modal = document.getElementById('transaction-modal');
|
const modal = document.getElementById('transaction-modal');
|
||||||
const modalTitle = document.getElementById('modal-title');
|
const modalTitle = document.getElementById('modal-title');
|
||||||
const modalThead = document.getElementById('modal-thead');
|
|
||||||
const modalTbody = document.getElementById('modal-tbody');
|
|
||||||
|
|
||||||
// Set title
|
// Set title
|
||||||
modalTitle.textContent = item.name;
|
modalTitle.textContent = item.name;
|
||||||
@ -1788,89 +1792,208 @@ function openTransactionModal(item) {
|
|||||||
transactions = [item];
|
transactions = [item];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear previous content
|
|
||||||
modalThead.innerHTML = '';
|
|
||||||
modalTbody.innerHTML = '';
|
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
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>';
|
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
setupModalListeners();
|
setupModalListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by date descending
|
// Get columns from first transaction
|
||||||
transactions.sort((a, b) => {
|
const sampleRow = transactions[0].originalRow;
|
||||||
const dateA = a.originalRow?.transaction_date || a.date || '';
|
currentColumns = csvColumnOrder.filter(col => col in sampleRow);
|
||||||
const dateB = b.originalRow?.transaction_date || b.date || '';
|
currentTransactions = transactions;
|
||||||
return dateB.localeCompare(dateA);
|
|
||||||
|
// 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
|
// Clear content
|
||||||
const sampleRow = transactions[0].originalRow;
|
modalThead.innerHTML = '';
|
||||||
const availableColumns = csvColumnOrder.filter(col => col in sampleRow);
|
modalTbody.innerHTML = '';
|
||||||
|
|
||||||
// Build header
|
// Build header with sort indicators
|
||||||
const headerRow = document.createElement('tr');
|
const headerRow = document.createElement('tr');
|
||||||
availableColumns.forEach(col => {
|
currentColumns.forEach(col => {
|
||||||
const th = document.createElement('th');
|
const th = document.createElement('th');
|
||||||
th.textContent = csvColumnLabels[col] || col;
|
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);
|
headerRow.appendChild(th);
|
||||||
});
|
});
|
||||||
modalThead.appendChild(headerRow);
|
modalThead.appendChild(headerRow);
|
||||||
|
|
||||||
// Build rows
|
// Build rows
|
||||||
transactions.forEach(transaction => {
|
sortedTransactions.forEach(transaction => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
availableColumns.forEach(col => {
|
|
||||||
|
currentColumns.forEach(col => {
|
||||||
const td = document.createElement('td');
|
const td = document.createElement('td');
|
||||||
let value = transaction.originalRow[col];
|
let value = transaction.originalRow[col];
|
||||||
|
|
||||||
// Format amount values
|
if ((col === 'amount_rub' || col === 'amount_original') && typeof value === 'number') {
|
||||||
if ((col === 'amount' || col === 'original_amount') && typeof value === 'number') {
|
|
||||||
value = value.toLocaleString();
|
value = value.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
td.textContent = value !== undefined && value !== null ? value : '';
|
td.textContent = value !== undefined && value !== null ? value : '';
|
||||||
td.title = td.textContent; // Show full text on hover
|
td.title = td.textContent;
|
||||||
row.appendChild(td);
|
row.appendChild(td);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row click opens detail modal
|
||||||
|
row.addEventListener('click', () => openRowDetailModal(transaction));
|
||||||
modalTbody.appendChild(row);
|
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';
|
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
|
// Close transaction modal
|
||||||
function closeTransactionModal() {
|
function closeTransactionModal() {
|
||||||
const modal = document.getElementById('transaction-modal');
|
const modal = document.getElementById('transaction-modal');
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
document.removeEventListener('keydown', handleModalEscape);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Escape key to close modal
|
// Global escape key handler for all modals
|
||||||
function handleModalEscape(e) {
|
function handleGlobalEscape(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeTransactionModal();
|
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
|
// Setup modal close listeners
|
||||||
function setupModalListeners() {
|
function setupModalListeners() {
|
||||||
const modal = document.getElementById('transaction-modal');
|
const modal = document.getElementById('transaction-modal');
|
||||||
const closeBtn = document.getElementById('modal-close');
|
const closeBtn = document.getElementById('modal-close');
|
||||||
|
|
||||||
// Close on X button click
|
|
||||||
closeBtn.onclick = closeTransactionModal;
|
closeBtn.onclick = closeTransactionModal;
|
||||||
|
|
||||||
// Close on backdrop click
|
|
||||||
modal.onclick = function(e) {
|
modal.onclick = function(e) {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
closeTransactionModal();
|
closeTransactionModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close on Escape key
|
|
||||||
document.addEventListener('keydown', handleModalEscape);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">×</button>
|
||||||
|
<h3 id="row-detail-title">Transaction Details</h3>
|
||||||
|
<div id="row-detail-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
108
styles.css
108
styles.css
@ -440,7 +440,7 @@ body {
|
|||||||
|
|
||||||
.modal-table-container {
|
.modal-table-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: scroll;
|
||||||
padding: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user