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_DB=ploughshares
|
||||||
- POSTGRES_USER=ploughshares
|
- POSTGRES_USER=ploughshares
|
||||||
- POSTGRES_PASSWORD=ploughshares_password
|
- POSTGRES_PASSWORD=ploughshares_password
|
||||||
- APP_VERSION=0.2.0
|
- APP_VERSION=0.4.0
|
||||||
- APP_ENV=development
|
- APP_ENV=development
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY schema.sql .
|
COPY schema.sql .
|
||||||
|
COPY migrate_approval.py .
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
# Tests directory is empty or doesn't contain required files
|
# 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
|
import logging
|
||||||
from flask_talisman import Talisman
|
from flask_talisman import Talisman
|
||||||
import re
|
import re
|
||||||
|
from migrate_approval import migrate_database
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
@ -263,8 +264,36 @@ def api_docs():
|
||||||
server_name = request.host
|
server_name = request.host
|
||||||
return render_template('api_docs.html', server_name=server_name, version=VERSION)
|
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>')
|
@app.route('/transaction/<int:id>')
|
||||||
def view_transaction(id):
|
def view_transaction(id):
|
||||||
|
"""
|
||||||
|
View a transaction
|
||||||
|
"""
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
if conn is None:
|
if conn is None:
|
||||||
flash("Database connection error", "error")
|
flash("Database connection error", "error")
|
||||||
|
@ -279,28 +308,61 @@ def view_transaction(id):
|
||||||
cur.execute('SELECT * FROM transactions WHERE id = %s', (id,))
|
cur.execute('SELECT * FROM transactions WHERE id = %s', (id,))
|
||||||
transaction = cur.fetchone()
|
transaction = cur.fetchone()
|
||||||
|
|
||||||
if transaction:
|
if transaction is None:
|
||||||
# Get previous transaction ID
|
abort(404)
|
||||||
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']
|
|
||||||
|
|
||||||
# Get next transaction ID
|
# Get previous and next transaction IDs
|
||||||
cur.execute('SELECT id FROM transactions WHERE id > %s ORDER BY id ASC LIMIT 1', (id,))
|
cur.execute('SELECT id FROM transactions WHERE id < %s ORDER BY id DESC LIMIT 1', (id,))
|
||||||
next_result = cur.fetchone()
|
prev_result = cur.fetchone()
|
||||||
if next_result:
|
prev_id = prev_result['id'] if prev_result else None
|
||||||
next_id = next_result['id']
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Database error: {e}")
|
logger.error(f"Database error: {e}")
|
||||||
flash(f"Database error: {e}", "error")
|
abort(404)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Get documents for this transaction
|
||||||
|
documents = get_transaction_documents(id)
|
||||||
|
|
||||||
if transaction is None:
|
if transaction is None:
|
||||||
abort(404)
|
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')
|
@app.route('/pending-approval')
|
||||||
def pending_approval():
|
def pending_approval():
|
||||||
|
@ -312,10 +374,19 @@ def pending_approval():
|
||||||
flash("Database connection error", "error")
|
flash("Database connection error", "error")
|
||||||
return render_template('pending_approval.html', transactions=[], version=VERSION)
|
return render_template('pending_approval.html', transactions=[], version=VERSION)
|
||||||
|
|
||||||
|
transactions = []
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute('SELECT * FROM transactions WHERE approved = FALSE ORDER BY id DESC')
|
cur.execute('SELECT * FROM transactions WHERE approved = FALSE ORDER BY id DESC')
|
||||||
transactions = cur.fetchall()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Database error: {e}")
|
logger.error(f"Database error: {e}")
|
||||||
flash(f"Database error: {e}", "error")
|
flash(f"Database error: {e}", "error")
|
||||||
|
@ -337,8 +408,8 @@ def approve_transaction(id):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# Get the approver name from the form or use default
|
# Use a default approver name instead of requiring user input
|
||||||
approver = request.form.get('approver', 'System')
|
approver = "System Administrator"
|
||||||
|
|
||||||
# Update the transaction
|
# Update the transaction
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
@ -402,9 +473,8 @@ def api_approve_transaction(id):
|
||||||
if conn is None:
|
if conn is None:
|
||||||
return jsonify({"error": "Database connection error"}), 500
|
return jsonify({"error": "Database connection error"}), 500
|
||||||
|
|
||||||
# Get the approver from the request data or use default
|
# Use the same default approver name for consistency
|
||||||
data = request.get_json() if request.is_json else {}
|
approver = "System Administrator"
|
||||||
approver = data.get('approver', 'API')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
@ -644,6 +714,11 @@ def bootstrap_database():
|
||||||
logger.error("Could not get count from database")
|
logger.error("Could not get count from database")
|
||||||
except Exception as count_error:
|
except Exception as count_error:
|
||||||
logger.error(f"Error counting transactions: {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:
|
except Exception as e:
|
||||||
logger.error(f"Error checking database: {e}")
|
logger.error(f"Error checking database: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
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():
|
def get_db_connection():
|
||||||
host = os.environ.get('POSTGRES_HOST', 'db')
|
host = os.environ.get('POSTGRES_HOST', 'db')
|
||||||
port = os.environ.get('POSTGRES_PORT', '5432')
|
port = os.environ.get('POSTGRES_PORT', '5432')
|
||||||
|
@ -31,15 +39,15 @@ def populate_test_data():
|
||||||
|
|
||||||
# Extract the transactions array
|
# Extract the transactions array
|
||||||
if 'transactions' not in data_json:
|
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
|
return
|
||||||
|
|
||||||
data = data_json['transactions']
|
data = data_json['transactions']
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print("Error: data.json file not found")
|
logger.error("data.json file not found")
|
||||||
return
|
return
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print("Error: data.json is not valid JSON")
|
logger.error("data.json is not valid JSON")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Insert sample transactions
|
# Insert sample transactions
|
||||||
|
@ -75,14 +83,14 @@ def populate_test_data():
|
||||||
item.get('contract_number', ''),
|
item.get('contract_number', ''),
|
||||||
item.get('comments', '')
|
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:
|
except Exception as e:
|
||||||
print(f"Error inserting transaction: {e}")
|
logger.error(f"Error inserting transaction: {e}")
|
||||||
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
print("Database populated with test data successfully.")
|
logger.info("Database populated with test data successfully.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
populate_test_data()
|
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);
|
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 */
|
/* Tooltip for hotkeys */
|
||||||
[data-hotkey]::after {
|
[data-hotkey]::after {
|
||||||
content: attr(data-hotkey);
|
content: attr(data-hotkey);
|
||||||
|
@ -64,11 +50,6 @@ kbd {
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hotkey-help-btn {
|
|
||||||
bottom: 10px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-hotkey]::after {
|
[data-hotkey]::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -348,27 +348,6 @@ function addHotkeyTooltips() {
|
||||||
// Get the current page based on URL
|
// Get the current page based on URL
|
||||||
const currentPath = window.location.pathname;
|
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
|
// Page-specific tooltips
|
||||||
if (currentPath === '/' || currentPath === '/index.html') {
|
if (currentPath === '/' || currentPath === '/index.html') {
|
||||||
// Transaction list page
|
// Transaction list page
|
||||||
|
|
|
@ -195,15 +195,11 @@
|
||||||
<p class="alert alert-warning">
|
<p class="alert alert-warning">
|
||||||
<i class="bi bi-exclamation-triangle"></i> <strong>Important:</strong> This endpoint provides human-in-the-loop verification.
|
<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.
|
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>
|
</p>
|
||||||
|
|
||||||
<h5>Complete Example:</h5>
|
<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" \
|
<pre class="bg-dark text-light p-2"><code id="approveTransaction">curl -X POST "http://{{ server_name }}/api/transaction/2/approve"</code></pre>
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"approver": "John Doe"
|
|
||||||
}'</code></pre>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('approveTransaction')">Copy</button>
|
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('approveTransaction')">Copy</button>
|
||||||
|
|
||||||
<h5>Response:</h5>
|
<h5>Response:</h5>
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
<th>Source Date</th>
|
<th>Source Date</th>
|
||||||
<th>Recipient</th>
|
<th>Recipient</th>
|
||||||
<th>Created At</th>
|
<th>Created At</th>
|
||||||
|
<th>Documents</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -42,6 +43,13 @@
|
||||||
<td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td>
|
<td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td>
|
||||||
<td>{{ transaction['recipient'] }}</td>
|
<td>{{ transaction['recipient'] }}</td>
|
||||||
<td>{{ transaction['created_at'].strftime('%Y-%m-%d %H:%M') if transaction['created_at'] else 'N/A' }}</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>
|
<td>
|
||||||
<div class="btn-group" role="group">
|
<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 '' }})">
|
<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 -->
|
<!-- Approve Modal -->
|
||||||
<div class="modal fade" id="approveModal{{ transaction['id'] }}" tabindex="-1" aria-labelledby="approveModalLabel{{ transaction['id'] }}" aria-hidden="true">
|
<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-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="approveModalLabel{{ transaction['id'] }}">Approve Transaction #{{ transaction['id'] }}</h5>
|
<h5 class="modal-title" id="approveModalLabel{{ transaction['id'] }}">Approve Transaction #{{ transaction['id'] }}</h5>
|
||||||
|
@ -65,10 +73,42 @@
|
||||||
</div>
|
</div>
|
||||||
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to approve this transaction?</p>
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-12">
|
||||||
<label for="approver{{ transaction['id'] }}" class="form-label">Your Name</label>
|
<h6>Transaction Details</h6>
|
||||||
<input type="text" class="form-control" id="approver{{ transaction['id'] }}" name="approver" required>
|
<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>
|
</div>
|
||||||
<div class="modal-footer">
|
<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
|
// Helper function to check if user is typing in an input field
|
||||||
function isUserTyping() {
|
function isUserTyping() {
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
<!-- Approve Modal -->
|
<!-- Approve Modal -->
|
||||||
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
|
<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-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="approveModalLabel">Approve Transaction #{{ transaction['id'] }}</h5>
|
<h5 class="modal-title" id="approveModalLabel">Approve Transaction #{{ transaction['id'] }}</h5>
|
||||||
|
@ -43,10 +43,48 @@
|
||||||
</div>
|
</div>
|
||||||
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to approve this transaction?</p>
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6">
|
||||||
<label for="approver" class="form-label">Your Name</label>
|
<h6>Transaction Details</h6>
|
||||||
<input type="text" class="form-control" id="approver" name="approver" required>
|
<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>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@ -87,6 +125,9 @@
|
||||||
<li class="nav-item" role="presentation">
|
<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>
|
<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>
|
||||||
|
<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>
|
</ul>
|
||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent">
|
||||||
<div class="tab-pane fade show active" id="details" role="tabpanel" aria-labelledby="details-tab">
|
<div class="tab-pane fade show active" id="details" role="tabpanel" aria-labelledby="details-tab">
|
||||||
|
@ -154,6 +195,44 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom navigation buttons for easier access -->
|
<!-- Bottom navigation buttons for easier access -->
|
||||||
|
@ -185,4 +264,91 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% 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 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: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 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