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
183
app.js
183
app.js
@ -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);
|
||||
}
|
||||
|
||||
@ -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">×</button>
|
||||
<h3 id="row-detail-title">Transaction Details</h3>
|
||||
<div id="row-detail-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
108
styles.css
108
styles.css
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user