Improved usability and accessibility:
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
- Added keyboard support for approval modals (Enter key) - Removed unnecessary keyboard shortcuts button - Fixed database schema issues - Added automatic approver name - Enhanced approval modals with document viewing - Version bump to 0.4.0
This commit is contained in:
parent
0179b6debf
commit
1c84db947c
|
@ -17,7 +17,7 @@ services:
|
|||
- POSTGRES_DB=ploughshares
|
||||
- POSTGRES_USER=ploughshares
|
||||
- POSTGRES_PASSWORD=ploughshares_password
|
||||
- APP_VERSION=0.2.0
|
||||
- APP_VERSION=0.4.0
|
||||
- APP_ENV=development
|
||||
depends_on:
|
||||
db:
|
||||
|
|
|
@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
# Copy application code
|
||||
COPY app.py .
|
||||
COPY schema.sql .
|
||||
COPY migrate_approval.py .
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
# Tests directory is empty or doesn't contain required files
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -8,6 +8,7 @@ import locale
|
|||
import logging
|
||||
from flask_talisman import Talisman
|
||||
import re
|
||||
from migrate_approval import migrate_database
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
|
@ -263,8 +264,36 @@ def api_docs():
|
|||
server_name = request.host
|
||||
return render_template('api_docs.html', server_name=server_name, version=VERSION)
|
||||
|
||||
def get_transaction_documents(transaction_id):
|
||||
"""
|
||||
Get all documents for a transaction
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
if conn is None:
|
||||
return []
|
||||
|
||||
documents = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('''
|
||||
SELECT document_id, filename, file_path, document_type, description, note, upload_date
|
||||
FROM transaction_documents
|
||||
WHERE transaction_id = %s
|
||||
ORDER BY upload_date DESC
|
||||
''', (transaction_id,))
|
||||
documents = cur.fetchall()
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching documents: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return documents
|
||||
|
||||
@app.route('/transaction/<int:id>')
|
||||
def view_transaction(id):
|
||||
"""
|
||||
View a transaction
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
if conn is None:
|
||||
flash("Database connection error", "error")
|
||||
|
@ -279,28 +308,61 @@ def view_transaction(id):
|
|||
cur.execute('SELECT * FROM transactions WHERE id = %s', (id,))
|
||||
transaction = cur.fetchone()
|
||||
|
||||
if transaction:
|
||||
# Get previous transaction ID
|
||||
cur.execute('SELECT id FROM transactions WHERE id < %s ORDER BY id DESC LIMIT 1', (id,))
|
||||
prev_result = cur.fetchone()
|
||||
if prev_result:
|
||||
prev_id = prev_result['id']
|
||||
if transaction is None:
|
||||
abort(404)
|
||||
|
||||
# Get next transaction ID
|
||||
cur.execute('SELECT id FROM transactions WHERE id > %s ORDER BY id ASC LIMIT 1', (id,))
|
||||
next_result = cur.fetchone()
|
||||
if next_result:
|
||||
next_id = next_result['id']
|
||||
# Get previous and next transaction IDs
|
||||
cur.execute('SELECT id FROM transactions WHERE id < %s ORDER BY id DESC LIMIT 1', (id,))
|
||||
prev_result = cur.fetchone()
|
||||
prev_id = prev_result['id'] if prev_result else None
|
||||
|
||||
cur.execute('SELECT id FROM transactions WHERE id > %s ORDER BY id ASC LIMIT 1', (id,))
|
||||
next_result = cur.fetchone()
|
||||
next_id = next_result['id'] if next_result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
flash(f"Database error: {e}", "error")
|
||||
abort(404)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Get documents for this transaction
|
||||
documents = get_transaction_documents(id)
|
||||
|
||||
if transaction is None:
|
||||
abort(404)
|
||||
|
||||
return render_template('view_transaction.html', transaction=transaction, prev_id=prev_id, next_id=next_id, version=VERSION)
|
||||
return render_template('view_transaction.html', transaction=transaction, documents=documents, prev_id=prev_id, next_id=next_id, version=VERSION)
|
||||
|
||||
@app.route('/document/<int:document_id>')
|
||||
def view_document(document_id):
|
||||
"""
|
||||
View a document
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
if conn is None:
|
||||
flash("Database connection error", "error")
|
||||
abort(404)
|
||||
|
||||
document = None
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT * FROM transaction_documents WHERE document_id = %s', (document_id,))
|
||||
document = cur.fetchone()
|
||||
|
||||
if document is None:
|
||||
abort(404)
|
||||
except Exception as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
abort(404)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if document is None:
|
||||
abort(404)
|
||||
|
||||
# Serve the file from the uploads directory
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], document['file_path'])
|
||||
return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=False)
|
||||
|
||||
@app.route('/pending-approval')
|
||||
def pending_approval():
|
||||
|
@ -312,10 +374,19 @@ def pending_approval():
|
|||
flash("Database connection error", "error")
|
||||
return render_template('pending_approval.html', transactions=[], version=VERSION)
|
||||
|
||||
transactions = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT * FROM transactions WHERE approved = FALSE ORDER BY id DESC')
|
||||
transactions = cur.fetchall()
|
||||
|
||||
# For each transaction, count the number of documents
|
||||
for transaction in transactions:
|
||||
cur.execute('SELECT COUNT(*) as doc_count FROM transaction_documents WHERE transaction_id = %s',
|
||||
(transaction['id'],))
|
||||
doc_count_result = cur.fetchone()
|
||||
transaction['document_count'] = doc_count_result['doc_count'] if doc_count_result else 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
flash(f"Database error: {e}", "error")
|
||||
|
@ -337,8 +408,8 @@ def approve_transaction(id):
|
|||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Get the approver name from the form or use default
|
||||
approver = request.form.get('approver', 'System')
|
||||
# Use a default approver name instead of requiring user input
|
||||
approver = "System Administrator"
|
||||
|
||||
# Update the transaction
|
||||
cur.execute(
|
||||
|
@ -402,9 +473,8 @@ def api_approve_transaction(id):
|
|||
if conn is None:
|
||||
return jsonify({"error": "Database connection error"}), 500
|
||||
|
||||
# Get the approver from the request data or use default
|
||||
data = request.get_json() if request.is_json else {}
|
||||
approver = data.get('approver', 'API')
|
||||
# Use the same default approver name for consistency
|
||||
approver = "System Administrator"
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
|
@ -644,6 +714,11 @@ def bootstrap_database():
|
|||
logger.error("Could not get count from database")
|
||||
except Exception as count_error:
|
||||
logger.error(f"Error counting transactions: {count_error}")
|
||||
|
||||
# Run database migrations to ensure all columns exist
|
||||
logger.info(f"Ploughshares v{VERSION} - Running database migrations...")
|
||||
migrate_database()
|
||||
logger.info(f"Ploughshares v{VERSION} - Database migrations completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking database: {e}")
|
||||
finally:
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
import os
|
||||
import psycopg2
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('ploughshares')
|
||||
|
||||
def get_db_connection():
|
||||
host = os.environ.get('POSTGRES_HOST', 'db')
|
||||
port = os.environ.get('POSTGRES_PORT', '5432')
|
||||
|
@ -31,15 +39,15 @@ def populate_test_data():
|
|||
|
||||
# Extract the transactions array
|
||||
if 'transactions' not in data_json:
|
||||
print("Error: 'transactions' key not found in data.json")
|
||||
logger.error("'transactions' key not found in data.json")
|
||||
return
|
||||
|
||||
data = data_json['transactions']
|
||||
except FileNotFoundError:
|
||||
print("Error: data.json file not found")
|
||||
logger.error("data.json file not found")
|
||||
return
|
||||
except json.JSONDecodeError:
|
||||
print("Error: data.json is not valid JSON")
|
||||
logger.error("data.json is not valid JSON")
|
||||
return
|
||||
|
||||
# Insert sample transactions
|
||||
|
@ -75,14 +83,14 @@ def populate_test_data():
|
|||
item.get('contract_number', ''),
|
||||
item.get('comments', '')
|
||||
))
|
||||
print(f"Added transaction: {item.get('company_division', 'Unknown')} - {item.get('recipient', 'Unknown')}")
|
||||
logger.info(f"Added transaction: {item.get('company_division', 'Unknown')} - {item.get('recipient', 'Unknown')}")
|
||||
except Exception as e:
|
||||
print(f"Error inserting transaction: {e}")
|
||||
logger.error(f"Error inserting transaction: {e}")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
print("Database populated with test data successfully.")
|
||||
logger.info("Database populated with test data successfully.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
populate_test_data()
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run this script to manually apply database migrations to add the 'approved' column
|
||||
to the transactions table if it doesn't exist.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from migrate_approval import migrate_database
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('ploughshares')
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting manual database migration...")
|
||||
migrate_database()
|
||||
logger.info("Database migration completed.")
|
|
@ -16,20 +16,6 @@ kbd {
|
|||
box-shadow: inset 0 -0.1rem 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Hotkey reference button */
|
||||
.hotkey-help-btn {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1030;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.hotkey-help-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tooltip for hotkeys */
|
||||
[data-hotkey]::after {
|
||||
content: attr(data-hotkey);
|
||||
|
@ -64,11 +50,6 @@ kbd {
|
|||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.hotkey-help-btn {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
[data-hotkey]::after {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -348,27 +348,6 @@ function addHotkeyTooltips() {
|
|||
// Get the current page based on URL
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
// Add tooltips for global elements
|
||||
const helpLink = document.createElement('div');
|
||||
helpLink.className = 'position-fixed bottom-0 end-0 p-3';
|
||||
helpLink.innerHTML = `
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="showHotkeyReference()"
|
||||
title="Show Keyboard Shortcuts (H)" id="hotkeyHelpButton">
|
||||
<i class="bi bi-keyboard"></i> <span class="d-none d-md-inline">Keyboard Shortcuts</span>
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(helpLink);
|
||||
|
||||
// Add click event listener directly
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hotkeyHelpButton = document.getElementById('hotkeyHelpButton');
|
||||
if (hotkeyHelpButton) {
|
||||
hotkeyHelpButton.addEventListener('click', function() {
|
||||
showHotkeyReference();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Page-specific tooltips
|
||||
if (currentPath === '/' || currentPath === '/index.html') {
|
||||
// Transaction list page
|
||||
|
|
|
@ -195,15 +195,11 @@
|
|||
<p class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i> <strong>Important:</strong> This endpoint provides human-in-the-loop verification.
|
||||
All transactions (especially those created via API) require explicit approval before being considered valid.
|
||||
The approver name is recorded for audit purposes.
|
||||
The system automatically records the approval with a standard identifier.
|
||||
</p>
|
||||
|
||||
<h5>Complete Example:</h5>
|
||||
<pre class="bg-dark text-light p-2"><code id="approveTransaction">curl -X POST "http://{{ server_name }}/api/transaction/2/approve" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"approver": "John Doe"
|
||||
}'</code></pre>
|
||||
<pre class="bg-dark text-light p-2"><code id="approveTransaction">curl -X POST "http://{{ server_name }}/api/transaction/2/approve"</code></pre>
|
||||
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('approveTransaction')">Copy</button>
|
||||
|
||||
<h5>Response:</h5>
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
<th>Source Date</th>
|
||||
<th>Recipient</th>
|
||||
<th>Created At</th>
|
||||
<th>Documents</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -42,6 +43,13 @@
|
|||
<td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td>
|
||||
<td>{{ transaction['recipient'] }}</td>
|
||||
<td>{{ transaction['created_at'].strftime('%Y-%m-%d %H:%M') if transaction['created_at'] else 'N/A' }}</td>
|
||||
<td>
|
||||
{% if transaction['document_count'] > 0 %}
|
||||
<span class="badge bg-info">{{ transaction['document_count'] }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('view_transaction', id=transaction['id']) }}" class="btn btn-sm btn-info" aria-label="View transaction {{ transaction['id'] }}" title="View ({{ loop.index if loop.index < 10 else '' }})">
|
||||
|
@ -57,7 +65,7 @@
|
|||
|
||||
<!-- Approve Modal -->
|
||||
<div class="modal fade" id="approveModal{{ transaction['id'] }}" tabindex="-1" aria-labelledby="approveModalLabel{{ transaction['id'] }}" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="approveModalLabel{{ transaction['id'] }}">Approve Transaction #{{ transaction['id'] }}</h5>
|
||||
|
@ -65,10 +73,42 @@
|
|||
</div>
|
||||
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to approve this transaction?</p>
|
||||
<div class="mb-3">
|
||||
<label for="approver{{ transaction['id'] }}" class="form-label">Your Name</label>
|
||||
<input type="text" class="form-control" id="approver{{ transaction['id'] }}" name="approver" required>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h6>Transaction Details</h6>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 20%;">Transaction Type:</th>
|
||||
<td>{{ transaction['transaction_type'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Company/Division:</th>
|
||||
<td>{{ transaction['company_division'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Amount:</th>
|
||||
<td><span class="currency-value">{{ transaction['amount']|currency }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Recipient:</th>
|
||||
<td>{{ transaction['recipient'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ transaction['description'] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('view_transaction', id=transaction['id']) }}" target="_blank" class="btn btn-info">
|
||||
<i class="bi bi-eye"></i> View Full Details and Documents
|
||||
</a>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3">
|
||||
<p>Are you sure you want to approve this transaction? The transaction will be marked as approved by the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -126,6 +166,29 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Enable Enter key for approval modals
|
||||
const approvalModals = document.querySelectorAll('[id^="approveModal"]');
|
||||
approvalModals.forEach(function(modal) {
|
||||
// When modal is shown, focus on the approve button
|
||||
modal.addEventListener('shown.bs.modal', function() {
|
||||
const approveButton = modal.querySelector('button[type="submit"]');
|
||||
if (approveButton) {
|
||||
approveButton.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Add keydown event listener for Enter key
|
||||
modal.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
const approveButton = modal.querySelector('button[type="submit"]');
|
||||
if (approveButton) {
|
||||
e.preventDefault();
|
||||
approveButton.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to check if user is typing in an input field
|
||||
function isUserTyping() {
|
||||
const activeElement = document.activeElement;
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
|
||||
<!-- Approve Modal -->
|
||||
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="approveModalLabel">Approve Transaction #{{ transaction['id'] }}</h5>
|
||||
|
@ -43,10 +43,48 @@
|
|||
</div>
|
||||
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to approve this transaction?</p>
|
||||
<div class="mb-3">
|
||||
<label for="approver" class="form-label">Your Name</label>
|
||||
<input type="text" class="form-control" id="approver" name="approver" required>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Transaction Details</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Type:</dt>
|
||||
<dd class="col-sm-8">{{ transaction['transaction_type'] }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Company:</dt>
|
||||
<dd class="col-sm-8">{{ transaction['company_division'] }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Amount:</dt>
|
||||
<dd class="col-sm-8">{{ transaction['amount']|currency }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Recipient:</dt>
|
||||
<dd class="col-sm-8">{{ transaction['recipient'] }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Description:</dt>
|
||||
<dd class="col-sm-8">{{ transaction['description'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Associated Documents</h6>
|
||||
{% if documents %}
|
||||
<div class="list-group">
|
||||
{% for doc in documents %}
|
||||
<a href="{{ url_for('view_document', document_id=doc['document_id']) }}" target="_blank" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ doc['filename'] }}</h6>
|
||||
<small>{{ doc['document_type'] }}</small>
|
||||
</div>
|
||||
<p class="mb-1">{{ doc['description'] }}</p>
|
||||
<small>{{ doc['upload_date'].strftime('%Y-%m-%d %H:%M') if doc['upload_date'] else 'N/A' }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No documents attached to this transaction.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3">
|
||||
<p>Are you sure you want to approve this transaction? The transaction will be marked as approved by the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -87,6 +125,9 @@
|
|||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" aria-controls="details" aria-selected="true">Details</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents" type="button" role="tab" aria-controls="documents" aria-selected="false">Documents</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<div class="tab-pane fade show active" id="details" role="tabpanel" aria-labelledby="details-tab">
|
||||
|
@ -154,6 +195,44 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="documents" role="tabpanel" aria-labelledby="documents-tab">
|
||||
<div class="p-3">
|
||||
{% if documents %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Note</th>
|
||||
<th>Upload Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in documents %}
|
||||
<tr>
|
||||
<td>{{ doc['filename'] }}</td>
|
||||
<td>{{ doc['document_type'] }}</td>
|
||||
<td>{{ doc['description'] }}</td>
|
||||
<td>{{ doc['note'] }}</td>
|
||||
<td>{{ doc['upload_date'].strftime('%Y-%m-%d %H:%M') if doc['upload_date'] else 'N/A' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('view_document', document_id=doc['document_id']) }}" target="_blank" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No documents attached to this transaction.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom navigation buttons for easier access -->
|
||||
|
@ -185,4 +264,91 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Enable Enter key for approval modal
|
||||
const approveModal = document.getElementById('approveModal');
|
||||
if (approveModal) {
|
||||
// When modal is shown, focus on the approve button
|
||||
approveModal.addEventListener('shown.bs.modal', function() {
|
||||
const approveButton = approveModal.querySelector('button[type="submit"]');
|
||||
if (approveButton) {
|
||||
approveButton.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Add keydown event listener for Enter key
|
||||
approveModal.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
const approveButton = approveModal.querySelector('button[type="submit"]');
|
||||
if (approveButton) {
|
||||
e.preventDefault();
|
||||
approveButton.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Transaction view keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (isModalOpen()) return; // Don't trigger shortcuts if a modal is open
|
||||
|
||||
switch(e.key) {
|
||||
case 'e': // Edit
|
||||
case 'E':
|
||||
const editBtn = document.querySelector('a[href$="/edit"]');
|
||||
if (editBtn) {
|
||||
e.preventDefault();
|
||||
editBtn.click();
|
||||
}
|
||||
break;
|
||||
case 'b': // Back
|
||||
case 'B':
|
||||
const backBtn = document.querySelector('a[href="/"]');
|
||||
if (backBtn) {
|
||||
e.preventDefault();
|
||||
backBtn.click();
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft': // Previous
|
||||
const prevBtn = document.querySelector('a[aria-label="Previous transaction"]');
|
||||
if (prevBtn) {
|
||||
e.preventDefault();
|
||||
prevBtn.click();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight': // Next
|
||||
const nextBtn = document.querySelector('a[aria-label="Next transaction"]');
|
||||
if (nextBtn) {
|
||||
e.preventDefault();
|
||||
nextBtn.click();
|
||||
}
|
||||
break;
|
||||
case 'a': // Approve
|
||||
case 'A':
|
||||
const approveBtn = document.querySelector('button[data-bs-target="#approveModal"]');
|
||||
if (approveBtn) {
|
||||
e.preventDefault();
|
||||
approveBtn.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function isModalOpen() {
|
||||
return document.querySelector('.modal.show') !== null;
|
||||
}
|
||||
|
||||
function isUserTyping() {
|
||||
const activeElement = document.activeElement;
|
||||
return activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.isContentEditable;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -4,3 +4,4 @@ Fri Jul 4 16:04:29 EDT 2025: Version changed from 0.1.2 to 0.2.0
|
|||
Fri Jul 4 20:03:15 EDT 2025: Version changed from 0.2.0 to 0.2.1
|
||||
Fri Jul 4 22:25:55 EDT 2025: Version changed from 0.2.1 to 0.3.0
|
||||
Fri Jul 4 22:33:48 EDT 2025: Version changed from 0.3.0 to 0.3.1
|
||||
Fri Jul 4 23:19:37 EDT 2025: Version changed from 0.3.1 to 0.4.0
|
||||
|
|
Loading…
Reference in New Issue