9 changed files with 2295 additions and 47 deletions
@ -0,0 +1,748 @@ |
|||||
|
{% extends "base.html" %} |
||||
|
|
||||
|
{% block title %}Add Payment Method - Plutus{% endblock %} |
||||
|
|
||||
|
{% block content %} |
||||
|
<nav class="breadcrumb" aria-label="breadcrumbs"> |
||||
|
<ul> |
||||
|
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li> |
||||
|
<li><a href="{{ url_for('main.single_payments_list') }}">Single Payments</a></li> |
||||
|
<li class="is-active"><a href="#" aria-current="page">Add Payment Method</a></li> |
||||
|
</ul> |
||||
|
</nav> |
||||
|
|
||||
|
<div class="level"> |
||||
|
<div class="level-left"> |
||||
|
<div> |
||||
|
<h1 class="title">Add Payment Method</h1> |
||||
|
<p class="subtitle">Add credit cards or BECS Direct Debit to customer accounts</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Add Payment Method Form --> |
||||
|
<div class="box"> |
||||
|
<!-- Step 1: Enter Splynx ID --> |
||||
|
<div id="step1" class="payment-step"> |
||||
|
<h2 class="title is-4"> |
||||
|
<span class="icon"><i class="fas fa-search"></i></span> |
||||
|
Customer Lookup |
||||
|
</h2> |
||||
|
|
||||
|
<div class="field"> |
||||
|
<label class="label" for="lookup_splynx_id">Splynx Customer ID</label> |
||||
|
<div class="control"> |
||||
|
<input class="input" type="number" id="lookup_splynx_id" placeholder="Enter customer ID" required> |
||||
|
</div> |
||||
|
<p class="help">Enter the Splynx customer ID to fetch customer details</p> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Loading State --> |
||||
|
<div id="loading" class="has-text-centered py-5 is-hidden"> |
||||
|
<div class="spinner"></div> |
||||
|
<p class="mt-3">Fetching customer details...</p> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Error State --> |
||||
|
<div id="customerError" class="notification is-danger is-hidden"> |
||||
|
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span> |
||||
|
<span id="errorMessage">Customer not found or error occurred</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="field is-grouped"> |
||||
|
<div class="control"> |
||||
|
<button class="button is-primary" id="nextBtn" onclick="fetchCustomerDetails()"> |
||||
|
<span class="icon"><i class="fas fa-arrow-right"></i></span> |
||||
|
<span>Next</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Step 2: Confirm Customer & Select Payment Method Type --> |
||||
|
<div id="step2" class="payment-step is-hidden"> |
||||
|
<h2 class="title is-4"> |
||||
|
<span class="icon"><i class="fas fa-user-check"></i></span> |
||||
|
Confirm Customer & Select Payment Method Type |
||||
|
</h2> |
||||
|
|
||||
|
<div class="box has-background-light mb-5"> |
||||
|
<h3 class="subtitle is-5">Customer Information</h3> |
||||
|
<div id="customerDetails"> |
||||
|
<!-- Customer details will be populated here --> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Current Payment Methods --> |
||||
|
<div class="box has-background-info-light mb-5"> |
||||
|
<h3 class="subtitle is-5"> |
||||
|
<span class="icon"><i class="fas fa-credit-card"></i></span> |
||||
|
Current Payment Methods |
||||
|
</h3> |
||||
|
<div id="currentPaymentMethods"> |
||||
|
<div class="has-text-centered py-4"> |
||||
|
<div class="spinner"></div> |
||||
|
<p class="mt-3">Loading current payment methods...</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="field"> |
||||
|
<label class="label">Payment Method Type</label> |
||||
|
<div class="control"> |
||||
|
<div class="columns"> |
||||
|
<div class="column"> |
||||
|
<div class="box payment-type-card" onclick="selectPaymentType('card')" id="cardOption"> |
||||
|
<div class="has-text-centered"> |
||||
|
<span class="icon is-large has-text-info"> |
||||
|
<i class="fas fa-credit-card fa-2x"></i> |
||||
|
</span> |
||||
|
<h4 class="title is-5 mt-3">Credit/Debit Card</h4> |
||||
|
<p class="content">Add a new credit or debit card</p> |
||||
|
<ul class="content is-small"> |
||||
|
<li>Domestic cards: 1.7% + $0.30</li> |
||||
|
<li>International cards: 3.5% + $0.30</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="column"> |
||||
|
<div class="box payment-type-card" onclick="selectPaymentType('au_becs_debit')" id="becsOption"> |
||||
|
<div class="has-text-centered"> |
||||
|
<span class="icon is-large has-text-success"> |
||||
|
<i class="fas fa-university fa-2x"></i> |
||||
|
</span> |
||||
|
<h4 class="title is-5 mt-3">BECS Direct Debit</h4> |
||||
|
<p class="content">Add Australian bank account</p> |
||||
|
<ul class="content is-small"> |
||||
|
<li>1.0% + $0.30 (capped at $3.50)</li> |
||||
|
<li>Lower fees than cards</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="field is-grouped"> |
||||
|
<div class="control"> |
||||
|
<button class="button is-light" onclick="goBackToStep1()"> |
||||
|
<span class="icon"><i class="fas fa-arrow-left"></i></span> |
||||
|
<span>Back</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="control"> |
||||
|
<button class="button is-primary" id="continueBtn" onclick="setupPaymentMethod()" disabled> |
||||
|
<span class="icon"><i class="fas fa-arrow-right"></i></span> |
||||
|
<span>Continue</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Step 3: Payment Method Collection --> |
||||
|
<div id="step3" class="payment-step is-hidden"> |
||||
|
<h2 class="title is-4"> |
||||
|
<span class="icon"><i class="fas fa-plus-circle"></i></span> |
||||
|
Add Payment Method |
||||
|
</h2> |
||||
|
|
||||
|
<div class="box has-background-light mb-5"> |
||||
|
<h3 class="subtitle is-5">Selected Type</h3> |
||||
|
<div id="selectedTypeDisplay"> |
||||
|
<!-- Selected payment method type will be shown here --> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Stripe Elements Container --> |
||||
|
<div class="field"> |
||||
|
<label class="label" id="paymentElementLabel">Payment Details</label> |
||||
|
<div class="control"> |
||||
|
<div id="payment-element" class="stripe-element"> |
||||
|
<!-- Stripe Elements will be mounted here --> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div id="payment-element-errors" role="alert" class="help is-danger is-hidden"></div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- BECS Mandate Agreement (only shown for BECS) --> |
||||
|
<div id="becsMandate" class="notification is-info is-hidden"> |
||||
|
<div class="content"> |
||||
|
<h4>Direct Debit Request Service Agreement</h4> |
||||
|
<p>By providing your bank account details and confirming this payment, you acknowledge that:</p> |
||||
|
<ul> |
||||
|
<li>You have read and agree to the Direct Debit Request Service Agreement</li> |
||||
|
<li>You authorize debits to your account according to the arrangement outlined</li> |
||||
|
<li>This authorization will remain in effect until cancelled by you</li> |
||||
|
</ul> |
||||
|
<label class="checkbox"> |
||||
|
<input type="checkbox" id="becsAgreement"> |
||||
|
I agree to the Direct Debit Request Service Agreement |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Set as Default Option --> |
||||
|
<div class="field"> |
||||
|
<div class="control"> |
||||
|
<label class="checkbox"> |
||||
|
<input type="checkbox" id="setAsDefault" checked> |
||||
|
Set as default payment method for this customer |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="field is-grouped"> |
||||
|
<div class="control"> |
||||
|
<button class="button is-light" onclick="goBackToStep2()"> |
||||
|
<span class="icon"><i class="fas fa-arrow-left"></i></span> |
||||
|
<span>Back</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="control"> |
||||
|
<button class="button is-primary" id="savePaymentMethodBtn" onclick="savePaymentMethod()"> |
||||
|
<span class="icon"><i class="fas fa-save"></i></span> |
||||
|
<span>Save Payment Method</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Processing State --> |
||||
|
<div id="processingPayment" class="notification is-info is-hidden"> |
||||
|
<div class="has-text-centered py-4"> |
||||
|
<div class="spinner"></div> |
||||
|
<p class="mt-3">Processing payment method...</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Step 4: Success --> |
||||
|
<div id="step4" class="payment-step is-hidden"> |
||||
|
<h2 class="title is-4 has-text-success"> |
||||
|
<span class="icon"><i class="fas fa-check-circle"></i></span> |
||||
|
Payment Method Added Successfully |
||||
|
</h2> |
||||
|
|
||||
|
<div class="box has-background-success-light"> |
||||
|
<div id="successDetails"> |
||||
|
<!-- Success details will be populated here --> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="field is-grouped"> |
||||
|
<div class="control"> |
||||
|
<button class="button is-success" onclick="addAnotherPaymentMethod()"> |
||||
|
<span class="icon"><i class="fas fa-plus"></i></span> |
||||
|
<span>Add Another Payment Method</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="control"> |
||||
|
<a class="button is-info" href="{{ url_for('main.single_payments_list') }}"> |
||||
|
<span class="icon"><i class="fas fa-list"></i></span> |
||||
|
<span>View Payments</span> |
||||
|
</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Include Stripe.js --> |
||||
|
<script src="https://js.stripe.com/v3/"></script> |
||||
|
|
||||
|
<script> |
||||
|
// Global variables |
||||
|
let currentCustomer = null; |
||||
|
let selectedPaymentType = null; |
||||
|
let stripe = null; |
||||
|
let elements = null; |
||||
|
let paymentElement = null; |
||||
|
let setupIntentClientSecret = null; |
||||
|
|
||||
|
// Initialize Stripe (we'll get the publishable key from the backend) |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
// We'll initialize Stripe when we need it in step 3 |
||||
|
}); |
||||
|
|
||||
|
function fetchCustomerDetails() { |
||||
|
const splynxId = document.getElementById('lookup_splynx_id').value; |
||||
|
|
||||
|
if (!splynxId) { |
||||
|
showError('Please enter a Splynx ID'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
showLoading(true); |
||||
|
hideError(); |
||||
|
|
||||
|
// Use existing API endpoint to get Stripe customer ID |
||||
|
fetch(`/api/stripe-customer-id/${splynxId}`) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
// Get Splynx customer details |
||||
|
return fetch(`/api/splynx/${splynxId}`) |
||||
|
.then(response => response.json()) |
||||
|
.then(splynxData => { |
||||
|
// Combine the data |
||||
|
const combinedData = { |
||||
|
success: true, |
||||
|
splynx_id: splynxId, |
||||
|
stripe_customer_id: data.stripe_customer_id, |
||||
|
customer_name: `${splynxData.name || ''} ${splynxData.lastname || ''}`.trim(), |
||||
|
customer_email: splynxData.email || '', |
||||
|
splynx_data: splynxData |
||||
|
}; |
||||
|
|
||||
|
currentCustomer = combinedData; |
||||
|
displayCustomerDetails(combinedData); |
||||
|
loadCurrentPaymentMethods(data.stripe_customer_id); |
||||
|
showStep(2); |
||||
|
showLoading(false); |
||||
|
}); |
||||
|
} else { |
||||
|
showLoading(false); |
||||
|
showError(data.error || 'Customer not found or no Stripe customer ID available'); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
showLoading(false); |
||||
|
showError('Error fetching customer details: ' + error.message); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function displayCustomerDetails(customer) { |
||||
|
const html = ` |
||||
|
<div class="columns"> |
||||
|
<div class="column"> |
||||
|
<strong>Name:</strong> ${customer.customer_name || 'N/A'}<br> |
||||
|
<strong>Email:</strong> ${customer.customer_email || 'N/A'}<br> |
||||
|
<strong>Splynx ID:</strong> ${customer.splynx_id} |
||||
|
</div> |
||||
|
<div class="column"> |
||||
|
<strong>Stripe Customer ID:</strong> ${customer.stripe_customer_id}<br> |
||||
|
<strong>Status:</strong> <span class="tag is-success">Active</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
`; |
||||
|
document.getElementById('customerDetails').innerHTML = html; |
||||
|
} |
||||
|
|
||||
|
function loadCurrentPaymentMethods(stripeCustomerId) { |
||||
|
fetch(`/api/stripe-payment-methods/${stripeCustomerId}`) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
displayCurrentPaymentMethods(data.payment_methods); |
||||
|
} else { |
||||
|
document.getElementById('currentPaymentMethods').innerHTML = |
||||
|
'<p class="has-text-danger">Error loading payment methods: ' + (data.error || 'Unknown error') + '</p>'; |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
document.getElementById('currentPaymentMethods').innerHTML = |
||||
|
'<p class="has-text-danger">Error loading payment methods: ' + error.message + '</p>'; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function displayCurrentPaymentMethods(methods) { |
||||
|
const container = document.getElementById('currentPaymentMethods'); |
||||
|
|
||||
|
if (!methods || methods.length === 0) { |
||||
|
container.innerHTML = '<p class="has-text-grey">No payment methods found for this customer.</p>'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let html = '<div class="columns is-multiline">'; |
||||
|
methods.forEach(method => { |
||||
|
let methodInfo = ''; |
||||
|
let icon = ''; |
||||
|
|
||||
|
if (method.type === 'card' && method.card) { |
||||
|
icon = 'fas fa-credit-card'; |
||||
|
methodInfo = `${method.card.brand.toUpperCase()} ending in ${method.card.last4}`; |
||||
|
} else if (method.type === 'au_becs_debit' && method.au_becs_debit) { |
||||
|
icon = 'fas fa-university'; |
||||
|
methodInfo = `Bank account ${method.au_becs_debit.bsb_number} ending in ${method.au_becs_debit.last4}`; |
||||
|
} else { |
||||
|
icon = 'fas fa-question-circle'; |
||||
|
methodInfo = `${method.type} payment method`; |
||||
|
} |
||||
|
|
||||
|
html += ` |
||||
|
<div class="column is-half"> |
||||
|
<div class="box is-small"> |
||||
|
<div class="media"> |
||||
|
<div class="media-left"> |
||||
|
<span class="icon has-text-info"> |
||||
|
<i class="${icon}"></i> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="media-content"> |
||||
|
<p class="is-size-6">${methodInfo}</p> |
||||
|
<p class="is-size-7 has-text-grey">Added: ${new Date(method.created * 1000).toLocaleDateString()}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
`; |
||||
|
}); |
||||
|
html += '</div>'; |
||||
|
|
||||
|
container.innerHTML = html; |
||||
|
} |
||||
|
|
||||
|
function selectPaymentType(type) { |
||||
|
selectedPaymentType = type; |
||||
|
|
||||
|
// Update UI |
||||
|
document.querySelectorAll('.payment-type-card').forEach(card => { |
||||
|
card.classList.remove('has-background-primary-light', 'has-border-primary'); |
||||
|
}); |
||||
|
|
||||
|
const selectedCard = document.getElementById(type === 'card' ? 'cardOption' : 'becsOption'); |
||||
|
selectedCard.classList.add('has-background-primary-light', 'has-border-primary'); |
||||
|
|
||||
|
document.getElementById('continueBtn').disabled = false; |
||||
|
} |
||||
|
|
||||
|
function setupPaymentMethod() { |
||||
|
if (!selectedPaymentType) { |
||||
|
showError('Please select a payment method type'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Update selected type display |
||||
|
const typeDisplay = selectedPaymentType === 'card' ? |
||||
|
'<span class="icon"><i class="fas fa-credit-card"></i></span> Credit/Debit Card' : |
||||
|
'<span class="icon"><i class="fas fa-university"></i></span> BECS Direct Debit'; |
||||
|
document.getElementById('selectedTypeDisplay').innerHTML = typeDisplay; |
||||
|
|
||||
|
// Show/hide BECS mandate |
||||
|
if (selectedPaymentType === 'au_becs_debit') { |
||||
|
document.getElementById('becsMandate').classList.remove('is-hidden'); |
||||
|
document.getElementById('paymentElementLabel').textContent = 'Bank Account Details'; |
||||
|
} else { |
||||
|
document.getElementById('becsMandate').classList.add('is-hidden'); |
||||
|
document.getElementById('paymentElementLabel').textContent = 'Card Details'; |
||||
|
} |
||||
|
|
||||
|
showStep(3); |
||||
|
initializeStripeElements(); |
||||
|
} |
||||
|
|
||||
|
function initializeStripeElements() { |
||||
|
console.log('Initializing Stripe elements for payment type:', selectedPaymentType); |
||||
|
console.log('Customer:', currentCustomer); |
||||
|
|
||||
|
// Get Stripe publishable key and create setup intent |
||||
|
fetch('/api/create-setup-intent', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
stripe_customer_id: currentCustomer.stripe_customer_id, |
||||
|
payment_method_types: [selectedPaymentType] |
||||
|
}) |
||||
|
}) |
||||
|
.then(response => { |
||||
|
console.log('Setup intent response status:', response.status); |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(data => { |
||||
|
console.log('Setup intent response data:', data); |
||||
|
if (data.success) { |
||||
|
setupIntentClientSecret = data.client_secret; |
||||
|
console.log('Client secret received:', data.client_secret.substring(0, 20) + '...'); |
||||
|
initializeStripe(data.stripe_publishable_key, data.client_secret); |
||||
|
} else { |
||||
|
console.error('Setup intent failed:', data.error); |
||||
|
showError('Failed to initialize payment method setup: ' + data.error); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Setup intent error:', error); |
||||
|
showError('Error initializing setup: ' + error.message); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function initializeStripe(publishableKey, clientSecret) { |
||||
|
console.log('Initializing Stripe with publishable key:', publishableKey.substring(0, 12) + '...'); |
||||
|
console.log('Selected payment type:', selectedPaymentType); |
||||
|
|
||||
|
stripe = Stripe(publishableKey); |
||||
|
|
||||
|
// Create elements with specific appearance and payment method options |
||||
|
const appearance = { |
||||
|
theme: 'stripe', |
||||
|
variables: { |
||||
|
colorPrimary: '#3273dc', |
||||
|
colorBackground: '#ffffff', |
||||
|
colorText: '#30313d', |
||||
|
colorDanger: '#df1b41', |
||||
|
fontFamily: 'Ideal Sans, system-ui, sans-serif', |
||||
|
spacingUnit: '2px', |
||||
|
borderRadius: '4px' |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
elements = stripe.elements({ |
||||
|
clientSecret: clientSecret, |
||||
|
appearance: appearance |
||||
|
}); |
||||
|
|
||||
|
// Configure payment element with specific payment method types |
||||
|
const paymentElementOptions = { |
||||
|
layout: 'tabs', |
||||
|
paymentMethodOrder: selectedPaymentType === 'card' |
||||
|
? ['card'] |
||||
|
: ['au_becs_debit'], |
||||
|
fields: { |
||||
|
billingDetails: { |
||||
|
name: 'auto', |
||||
|
email: 'auto' |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
console.log('Creating payment element with options:', paymentElementOptions); |
||||
|
|
||||
|
paymentElement = elements.create('payment', paymentElementOptions); |
||||
|
|
||||
|
console.log('Mounting payment element to #payment-element'); |
||||
|
const mountElement = document.getElementById('payment-element'); |
||||
|
if (mountElement) { |
||||
|
console.log('Mount element found:', mountElement); |
||||
|
try { |
||||
|
paymentElement.mount('#payment-element'); |
||||
|
console.log('Payment element mounted successfully'); |
||||
|
} catch (error) { |
||||
|
console.error('Error mounting payment element:', error); |
||||
|
showError('Error mounting payment form: ' + error.message); |
||||
|
} |
||||
|
} else { |
||||
|
console.error('Mount element #payment-element not found'); |
||||
|
showError('Payment form container not found'); |
||||
|
} |
||||
|
|
||||
|
paymentElement.on('change', function(event) { |
||||
|
console.log('Payment element change event:', event); |
||||
|
const errorElement = document.getElementById('payment-element-errors'); |
||||
|
if (event.error) { |
||||
|
errorElement.textContent = event.error.message; |
||||
|
errorElement.classList.remove('is-hidden'); |
||||
|
} else { |
||||
|
errorElement.textContent = ''; |
||||
|
errorElement.classList.add('is-hidden'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
paymentElement.on('ready', function() { |
||||
|
console.log('Payment element is ready and visible'); |
||||
|
}); |
||||
|
|
||||
|
paymentElement.on('loaderror', function(event) { |
||||
|
console.error('Payment element load error:', event); |
||||
|
showError('Error loading payment form: ' + event.error.message); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function savePaymentMethod() { |
||||
|
// Validate BECS agreement if needed |
||||
|
if (selectedPaymentType === 'au_becs_debit') { |
||||
|
const becsAgreement = document.getElementById('becsAgreement'); |
||||
|
if (!becsAgreement.checked) { |
||||
|
showError('Please agree to the Direct Debit Request Service Agreement'); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
||||
|
saveBtn.disabled = true; |
||||
|
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>'; |
||||
|
|
||||
|
document.getElementById('processingPayment').classList.remove('is-hidden'); |
||||
|
|
||||
|
// Confirm the setup intent |
||||
|
stripe.confirmSetup({ |
||||
|
elements, |
||||
|
confirmParams: { |
||||
|
return_url: window.location.origin + '/single-payments/add-payment-method/success', |
||||
|
}, |
||||
|
redirect: 'if_required' |
||||
|
}).then(function(result) { |
||||
|
if (result.error) { |
||||
|
// Handle error |
||||
|
showError('Payment method setup failed: ' + result.error.message); |
||||
|
saveBtn.disabled = false; |
||||
|
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
||||
|
document.getElementById('processingPayment').classList.add('is-hidden'); |
||||
|
} else { |
||||
|
// Setup succeeded |
||||
|
handleSetupSuccess(result.setupIntent); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function handleSetupSuccess(setupIntent) { |
||||
|
const setAsDefault = document.getElementById('setAsDefault').checked; |
||||
|
|
||||
|
// Attach payment method and optionally set as default |
||||
|
fetch('/api/finalize-payment-method', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
setup_intent_id: setupIntent.id, |
||||
|
stripe_customer_id: currentCustomer.stripe_customer_id, |
||||
|
set_as_default: setAsDefault, |
||||
|
splynx_id: currentCustomer.splynx_id |
||||
|
}) |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
document.getElementById('processingPayment').classList.add('is-hidden'); |
||||
|
|
||||
|
if (data.success) { |
||||
|
displaySuccess(data); |
||||
|
showStep(4); |
||||
|
} else { |
||||
|
showError('Failed to finalize payment method: ' + data.error); |
||||
|
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
||||
|
saveBtn.disabled = false; |
||||
|
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
document.getElementById('processingPayment').classList.add('is-hidden'); |
||||
|
showError('Error finalizing setup: ' + error.message); |
||||
|
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
||||
|
saveBtn.disabled = false; |
||||
|
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function displaySuccess(data) { |
||||
|
const paymentMethod = data.payment_method; |
||||
|
let methodInfo = ''; |
||||
|
|
||||
|
if (paymentMethod.type === 'card' && paymentMethod.card) { |
||||
|
methodInfo = `${paymentMethod.card.brand.toUpperCase()} ending in ${paymentMethod.card.last4}`; |
||||
|
} else if (paymentMethod.type === 'au_becs_debit' && paymentMethod.au_becs_debit) { |
||||
|
methodInfo = `Bank account ${paymentMethod.au_becs_debit.bsb_number} ending in ${paymentMethod.au_becs_debit.last4}`; |
||||
|
} |
||||
|
|
||||
|
const html = ` |
||||
|
<div class="content"> |
||||
|
<h4>Payment Method Added</h4> |
||||
|
<p><strong>Type:</strong> ${methodInfo}</p> |
||||
|
<p><strong>Customer:</strong> ${currentCustomer.customer_name} (${currentCustomer.customer_email})</p> |
||||
|
<p><strong>Set as Default:</strong> ${data.is_default ? 'Yes' : 'No'}</p> |
||||
|
<p><strong>Payment Method ID:</strong> <code>${paymentMethod.id}</code></p> |
||||
|
</div> |
||||
|
`; |
||||
|
document.getElementById('successDetails').innerHTML = html; |
||||
|
} |
||||
|
|
||||
|
// Utility functions |
||||
|
function showStep(step) { |
||||
|
document.querySelectorAll('.payment-step').forEach(div => div.classList.add('is-hidden')); |
||||
|
document.getElementById('step' + step).classList.remove('is-hidden'); |
||||
|
} |
||||
|
|
||||
|
function goBackToStep1() { |
||||
|
showStep(1); |
||||
|
selectedPaymentType = null; |
||||
|
} |
||||
|
|
||||
|
function goBackToStep2() { |
||||
|
showStep(2); |
||||
|
} |
||||
|
|
||||
|
function addAnotherPaymentMethod() { |
||||
|
// Reset form |
||||
|
document.getElementById('lookup_splynx_id').value = ''; |
||||
|
currentCustomer = null; |
||||
|
selectedPaymentType = null; |
||||
|
setupIntentClientSecret = null; |
||||
|
showStep(1); |
||||
|
} |
||||
|
|
||||
|
function showLoading(show) { |
||||
|
const loading = document.getElementById('loading'); |
||||
|
const nextBtn = document.getElementById('nextBtn'); |
||||
|
|
||||
|
if (show) { |
||||
|
loading.classList.remove('is-hidden'); |
||||
|
nextBtn.disabled = true; |
||||
|
} else { |
||||
|
loading.classList.add('is-hidden'); |
||||
|
nextBtn.disabled = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function showError(message) { |
||||
|
document.getElementById('errorMessage').textContent = message; |
||||
|
document.getElementById('customerError').classList.remove('is-hidden'); |
||||
|
} |
||||
|
|
||||
|
function hideError() { |
||||
|
document.getElementById('customerError').classList.add('is-hidden'); |
||||
|
} |
||||
|
|
||||
|
// Allow Enter key to trigger next button in step 1 |
||||
|
document.getElementById('lookup_splynx_id').addEventListener('keypress', function(e) { |
||||
|
if (e.key === 'Enter') { |
||||
|
fetchCustomerDetails(); |
||||
|
} |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.payment-type-card { |
||||
|
cursor: pointer; |
||||
|
border: 2px solid transparent; |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.payment-type-card:hover { |
||||
|
border-color: #dbdbdb; |
||||
|
} |
||||
|
|
||||
|
.has-border-primary { |
||||
|
border-color: #3273dc !important; |
||||
|
} |
||||
|
|
||||
|
#payment-element { |
||||
|
border: 1px solid #dbdbdb; |
||||
|
border-radius: 4px; |
||||
|
padding: 12px; |
||||
|
background-color: white; |
||||
|
min-height: 60px; |
||||
|
} |
||||
|
|
||||
|
.stripe-element { |
||||
|
border: 1px solid #dbdbdb; |
||||
|
border-radius: 4px; |
||||
|
padding: 12px; |
||||
|
background-color: white; |
||||
|
} |
||||
|
|
||||
|
.spinner { |
||||
|
width: 40px; |
||||
|
height: 40px; |
||||
|
border: 4px solid #f3f3f3; |
||||
|
border-top: 4px solid #3273dc; |
||||
|
border-radius: 50%; |
||||
|
animation: spin 1s linear infinite; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
@keyframes spin { |
||||
|
0% { transform: rotate(0deg); } |
||||
|
100% { transform: rotate(360deg); } |
||||
|
} |
||||
|
</style> |
||||
|
{% endblock %} |
||||
@ -0,0 +1,510 @@ |
|||||
|
{% extends "base.html" %} |
||||
|
|
||||
|
{% block title %}System Logs - Plutus{% endblock %} |
||||
|
|
||||
|
{% block content %} |
||||
|
<nav class="breadcrumb" aria-label="breadcrumbs"> |
||||
|
<ul> |
||||
|
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li> |
||||
|
<li class="is-active"><a href="#" aria-current="page">System Logs</a></li> |
||||
|
</ul> |
||||
|
</nav> |
||||
|
|
||||
|
<div class="level"> |
||||
|
<div class="level-left"> |
||||
|
<div> |
||||
|
<h1 class="title">System Logs</h1> |
||||
|
<p class="subtitle">User activity and system audit trail</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="level-right"> |
||||
|
<div class="field is-grouped"> |
||||
|
<div class="control"> |
||||
|
<button class="button is-info" onclick="exportLogs()"> |
||||
|
<span class="icon"><i class="fas fa-download"></i></span> |
||||
|
<span>Export Logs</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Filter Controls --> |
||||
|
<div class="box"> |
||||
|
<h2 class="title is-5"> |
||||
|
<span class="icon"><i class="fas fa-filter"></i></span> |
||||
|
Filters |
||||
|
</h2> |
||||
|
|
||||
|
<div class="field is-grouped is-grouped-multiline"> |
||||
|
<div class="control"> |
||||
|
<label class="label is-small">Search:</label> |
||||
|
<div class="field has-addons"> |
||||
|
<div class="control has-icons-left is-expanded"> |
||||
|
<input class="input" type="text" id="searchInput" placeholder="Search logs, actions, details..."> |
||||
|
<span class="icon is-small is-left"> |
||||
|
<i class="fas fa-search"></i> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<label class="label is-small">User:</label> |
||||
|
<div class="select"> |
||||
|
<select id="userFilter"> |
||||
|
<option value="">All Users</option> |
||||
|
{% for user in users %} |
||||
|
<option value="{{ user.id }}">{{ user.FullName }}</option> |
||||
|
{% endfor %} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<label class="label is-small">Action:</label> |
||||
|
<div class="select"> |
||||
|
<select id="actionFilter"> |
||||
|
<option value="">All Actions</option> |
||||
|
{% for action in actions %} |
||||
|
<option value="{{ action }}">{{ action }}</option> |
||||
|
{% endfor %} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<label class="label is-small">Entity Type:</label> |
||||
|
<div class="select"> |
||||
|
<select id="entityTypeFilter"> |
||||
|
<option value="">All Types</option> |
||||
|
{% for entity_type in entity_types %} |
||||
|
<option value="{{ entity_type }}">{{ entity_type }}</option> |
||||
|
{% endfor %} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<label class="label is-small">Date From:</label> |
||||
|
<input class="input" type="date" id="dateFromFilter"> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<label class="label is-small">Date To:</label> |
||||
|
<input class="input" type="date" id="dateToFilter"> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<button class="button is-small is-info" onclick="applyFilters()"> |
||||
|
<span class="icon"><i class="fas fa-search"></i></span> |
||||
|
<span>Apply Filters</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<button class="button is-small is-light" onclick="clearFilters()"> |
||||
|
<span class="icon"><i class="fas fa-times"></i></span> |
||||
|
<span>Clear</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Results Summary --> |
||||
|
<div class="notification is-info is-light" id="filterResults" style="display: none;"> |
||||
|
<span id="resultCount">0</span> of {{ logs|length }} log entries shown |
||||
|
</div> |
||||
|
|
||||
|
<!-- Logs Table --> |
||||
|
<div class="box"> |
||||
|
<h2 class="title is-5"> |
||||
|
<span class="icon"><i class="fas fa-list"></i></span> |
||||
|
Log Entries |
||||
|
</h2> |
||||
|
|
||||
|
{% if logs %} |
||||
|
<div class="table-container"> |
||||
|
<table class="table is-fullwidth is-striped is-hoverable" id="logsTable"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Timestamp</th> |
||||
|
<th>User</th> |
||||
|
<th>Action</th> |
||||
|
<th>Entity</th> |
||||
|
<th>Details</th> |
||||
|
<th>IP Address</th> |
||||
|
<th>Actions</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody id="logsTableBody"> |
||||
|
{% for log in logs %} |
||||
|
<tr> |
||||
|
<td> |
||||
|
<span class="is-size-7">{{ log.Added.strftime('%Y-%m-%d') }}</span><br> |
||||
|
<span class="is-size-7 has-text-grey">{{ log.Added.strftime('%H:%M:%S') }}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<div class="media"> |
||||
|
<div class="media-content"> |
||||
|
<strong>{{ log.user_name or 'System' }}</strong> |
||||
|
{% if log.User_ID %} |
||||
|
<br><small class="has-text-grey">ID: {{ log.User_ID }}</small> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td> |
||||
|
{% if log.Action %} |
||||
|
<span class="tag is-info is-light">{{ log.Action }}</span> |
||||
|
{% else %} |
||||
|
<span class="has-text-grey">-</span> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
{% if log.Entity_Type %} |
||||
|
<div> |
||||
|
<span class="tag is-primary is-light">{{ log.Entity_Type }}</span> |
||||
|
{% if log.Entity_ID %} |
||||
|
<br><small class="has-text-grey">ID: {{ log.Entity_ID }}</small> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
{% else %} |
||||
|
<span class="has-text-grey">-</span> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
{% if log.Log_Entry %} |
||||
|
<div class="content is-small"> |
||||
|
{{ log.Log_Entry[:100] }}{% if log.Log_Entry|length > 100 %}...{% endif %} |
||||
|
</div> |
||||
|
{% else %} |
||||
|
<span class="has-text-grey">-</span> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
{% if log.IP_Address %} |
||||
|
<code class="is-small">{{ log.IP_Address }}</code> |
||||
|
{% else %} |
||||
|
<span class="has-text-grey">-</span> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
<div class="buttons are-small"> |
||||
|
<button class="button is-info is-outlined" onclick="showLogDetail({{ log.id }})"> |
||||
|
<span class="icon"><i class="fas fa-eye"></i></span> |
||||
|
<span>View</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
{% endfor %} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Pagination --> |
||||
|
{% if pagination %} |
||||
|
<nav class="pagination is-centered" role="navigation" aria-label="pagination"> |
||||
|
{% if pagination.has_prev %} |
||||
|
<a class="pagination-previous" href="{{ url_for('main.logs_list', page=pagination.prev_num, **request.args) }}">Previous</a> |
||||
|
{% else %} |
||||
|
<a class="pagination-previous" disabled>Previous</a> |
||||
|
{% endif %} |
||||
|
|
||||
|
{% if pagination.has_next %} |
||||
|
<a class="pagination-next" href="{{ url_for('main.logs_list', page=pagination.next_num, **request.args) }}">Next page</a> |
||||
|
{% else %} |
||||
|
<a class="pagination-next" disabled>Next page</a> |
||||
|
{% endif %} |
||||
|
|
||||
|
<ul class="pagination-list"> |
||||
|
{% for page_num in pagination.iter_pages() %} |
||||
|
{% if page_num %} |
||||
|
{% if page_num != pagination.page %} |
||||
|
<li><a class="pagination-link" href="{{ url_for('main.logs_list', page=page_num, **request.args) }}">{{ page_num }}</a></li> |
||||
|
{% else %} |
||||
|
<li><a class="pagination-link is-current" href="#">{{ page_num }}</a></li> |
||||
|
{% endif %} |
||||
|
{% else %} |
||||
|
<li><span class="pagination-ellipsis">…</span></li> |
||||
|
{% endif %} |
||||
|
{% endfor %} |
||||
|
</ul> |
||||
|
</nav> |
||||
|
{% endif %} |
||||
|
|
||||
|
{% else %} |
||||
|
<div class="notification is-info"> |
||||
|
<p>No log entries found.</p> |
||||
|
</div> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
|
||||
|
<!-- Log Detail Modal --> |
||||
|
<div class="modal" id="logDetailModal"> |
||||
|
<div class="modal-background" onclick="hideModal('logDetailModal')"></div> |
||||
|
<div class="modal-card"> |
||||
|
<header class="modal-card-head"> |
||||
|
<p class="modal-card-title"> |
||||
|
<span class="icon"><i class="fas fa-file-alt"></i></span> |
||||
|
Log Entry Details |
||||
|
</p> |
||||
|
<button class="delete" aria-label="close" onclick="hideModal('logDetailModal')"></button> |
||||
|
</header> |
||||
|
<section class="modal-card-body"> |
||||
|
<div id="logDetailContent"> |
||||
|
<!-- Log details will be populated here --> |
||||
|
</div> |
||||
|
</section> |
||||
|
<footer class="modal-card-foot"> |
||||
|
<button class="button is-info" onclick="copyLogDetails()"> |
||||
|
<span class="icon"><i class="fas fa-copy"></i></span> |
||||
|
<span>Copy Details</span> |
||||
|
</button> |
||||
|
<button class="button" onclick="hideModal('logDetailModal')">Close</button> |
||||
|
</footer> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
let allLogs = []; |
||||
|
let filteredLogs = []; |
||||
|
|
||||
|
// Initialize logs and filters when page loads |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
initializeLogs(); |
||||
|
setupEventListeners(); |
||||
|
}); |
||||
|
|
||||
|
function initializeLogs() { |
||||
|
const tableBody = document.getElementById('logsTableBody'); |
||||
|
const rows = tableBody.querySelectorAll('tr'); |
||||
|
|
||||
|
allLogs = Array.from(rows).map(row => { |
||||
|
const cells = row.querySelectorAll('td'); |
||||
|
return { |
||||
|
element: row, |
||||
|
timestamp: cells[0] ? (cells[0].textContent.trim() || '') : '', |
||||
|
user: cells[1] ? (cells[1].textContent.trim() || '') : '', |
||||
|
action: cells[2] ? (cells[2].textContent.trim() || '') : '', |
||||
|
entityType: cells[3] ? (cells[3].textContent.trim() || '') : '', |
||||
|
details: cells[4] ? (cells[4].textContent.trim() || '') : '', |
||||
|
ipAddress: cells[5] ? (cells[5].textContent.trim() || '') : '' |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
filteredLogs = [...allLogs]; |
||||
|
updateResultCount(); |
||||
|
} |
||||
|
|
||||
|
function setupEventListeners() { |
||||
|
document.getElementById('searchInput').addEventListener('input', applyFilters); |
||||
|
document.getElementById('userFilter').addEventListener('change', applyFilters); |
||||
|
document.getElementById('actionFilter').addEventListener('change', applyFilters); |
||||
|
document.getElementById('entityTypeFilter').addEventListener('change', applyFilters); |
||||
|
document.getElementById('dateFromFilter').addEventListener('change', applyFilters); |
||||
|
document.getElementById('dateToFilter').addEventListener('change', applyFilters); |
||||
|
} |
||||
|
|
||||
|
function applyFilters() { |
||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase(); |
||||
|
const userFilter = document.getElementById('userFilter').value; |
||||
|
const actionFilter = document.getElementById('actionFilter').value; |
||||
|
const entityTypeFilter = document.getElementById('entityTypeFilter').value; |
||||
|
const dateFromFilter = document.getElementById('dateFromFilter').value; |
||||
|
const dateToFilter = document.getElementById('dateToFilter').value; |
||||
|
|
||||
|
// Filter logs |
||||
|
filteredLogs = allLogs.filter(log => { |
||||
|
// Search filter |
||||
|
const searchMatch = !searchTerm || |
||||
|
log.user.toLowerCase().includes(searchTerm) || |
||||
|
log.action.toLowerCase().includes(searchTerm) || |
||||
|
log.entityType.toLowerCase().includes(searchTerm) || |
||||
|
log.details.toLowerCase().includes(searchTerm) || |
||||
|
log.ipAddress.toLowerCase().includes(searchTerm); |
||||
|
|
||||
|
// User filter |
||||
|
const userMatch = !userFilter || log.user.includes(`ID: ${userFilter}`); |
||||
|
|
||||
|
// Action filter |
||||
|
const actionMatch = !actionFilter || log.action.includes(actionFilter); |
||||
|
|
||||
|
// Entity type filter |
||||
|
const entityTypeMatch = !entityTypeFilter || log.entityType.includes(entityTypeFilter); |
||||
|
|
||||
|
// Date filters would need server-side implementation for full functionality |
||||
|
// For now, we'll implement basic client-side date filtering on visible text |
||||
|
let dateMatch = true; |
||||
|
if (dateFromFilter || dateToFilter) { |
||||
|
const logDate = log.timestamp.split(' ')[0]; // Get just the date part |
||||
|
if (dateFromFilter && logDate < dateFromFilter) dateMatch = false; |
||||
|
if (dateToFilter && logDate > dateToFilter) dateMatch = false; |
||||
|
} |
||||
|
|
||||
|
return searchMatch && userMatch && actionMatch && entityTypeMatch && dateMatch; |
||||
|
}); |
||||
|
|
||||
|
// Update display |
||||
|
updateTable(); |
||||
|
updateResultCount(); |
||||
|
} |
||||
|
|
||||
|
function updateTable() { |
||||
|
const tableBody = document.getElementById('logsTableBody'); |
||||
|
|
||||
|
// Hide all rows first |
||||
|
allLogs.forEach(log => { |
||||
|
log.element.style.display = 'none'; |
||||
|
}); |
||||
|
|
||||
|
// Show filtered rows |
||||
|
filteredLogs.forEach(log => { |
||||
|
log.element.style.display = ''; |
||||
|
tableBody.appendChild(log.element); // Re-append to maintain order |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function updateResultCount() { |
||||
|
const resultCount = document.getElementById('resultCount'); |
||||
|
const filterResults = document.getElementById('filterResults'); |
||||
|
|
||||
|
resultCount.textContent = filteredLogs.length; |
||||
|
|
||||
|
if (filteredLogs.length === allLogs.length) { |
||||
|
filterResults.style.display = 'none'; |
||||
|
} else { |
||||
|
filterResults.style.display = 'block'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function clearFilters() { |
||||
|
document.getElementById('searchInput').value = ''; |
||||
|
document.getElementById('userFilter').value = ''; |
||||
|
document.getElementById('actionFilter').value = ''; |
||||
|
document.getElementById('entityTypeFilter').value = ''; |
||||
|
document.getElementById('dateFromFilter').value = ''; |
||||
|
document.getElementById('dateToFilter').value = ''; |
||||
|
applyFilters(); |
||||
|
} |
||||
|
|
||||
|
function showLogDetail(logId) { |
||||
|
// Fetch log details via AJAX |
||||
|
fetch(`/logs/detail/${logId}`) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
const log = data.log; |
||||
|
const detailHtml = ` |
||||
|
<div class="content"> |
||||
|
<table class="table is-fullwidth"> |
||||
|
<tbody> |
||||
|
<tr> |
||||
|
<td><strong>ID</strong></td> |
||||
|
<td>#${log.id}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>Timestamp</strong></td> |
||||
|
<td>${log.timestamp}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>User</strong></td> |
||||
|
<td>${log.user_name || 'System'} ${log.User_ID ? `(ID: ${log.User_ID})` : ''}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>Action</strong></td> |
||||
|
<td>${log.Action || '-'}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>Entity Type</strong></td> |
||||
|
<td>${log.Entity_Type || '-'}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>Entity ID</strong></td> |
||||
|
<td>${log.Entity_ID || '-'}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>IP Address</strong></td> |
||||
|
<td>${log.IP_Address || '-'}</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
|
||||
|
${log.Log_Entry ? ` |
||||
|
<div class="field"> |
||||
|
<label class="label">Full Details:</label> |
||||
|
<div class="box"> |
||||
|
<pre class="has-text-dark">${log.Log_Entry}</pre> |
||||
|
</div> |
||||
|
</div> |
||||
|
` : ''} |
||||
|
</div> |
||||
|
`; |
||||
|
|
||||
|
document.getElementById('logDetailContent').innerHTML = detailHtml; |
||||
|
document.getElementById('logDetailModal').classList.add('is-active'); |
||||
|
} else { |
||||
|
alert('Failed to load log details: ' + data.error); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error fetching log details:', error); |
||||
|
alert('Failed to load log details. Please try again.'); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function hideModal(modalId) { |
||||
|
document.getElementById(modalId).classList.remove('is-active'); |
||||
|
} |
||||
|
|
||||
|
function copyLogDetails() { |
||||
|
const content = document.getElementById('logDetailContent').innerText; |
||||
|
navigator.clipboard.writeText(content).then(function() { |
||||
|
// Show temporary success message |
||||
|
const button = event.target.closest('button'); |
||||
|
const originalText = button.innerHTML; |
||||
|
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>'; |
||||
|
button.classList.add('is-success'); |
||||
|
|
||||
|
setTimeout(function() { |
||||
|
button.innerHTML = originalText; |
||||
|
button.classList.remove('is-success'); |
||||
|
}, 2000); |
||||
|
}).catch(function(err) { |
||||
|
console.error('Failed to copy text: ', err); |
||||
|
alert('Failed to copy to clipboard'); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function exportLogs() { |
||||
|
// Create form data with current filters |
||||
|
const params = new URLSearchParams(); |
||||
|
|
||||
|
const searchTerm = document.getElementById('searchInput').value; |
||||
|
const userFilter = document.getElementById('userFilter').value; |
||||
|
const actionFilter = document.getElementById('actionFilter').value; |
||||
|
const entityTypeFilter = document.getElementById('entityTypeFilter').value; |
||||
|
const dateFromFilter = document.getElementById('dateFromFilter').value; |
||||
|
const dateToFilter = document.getElementById('dateToFilter').value; |
||||
|
|
||||
|
if (searchTerm) params.append('search', searchTerm); |
||||
|
if (userFilter) params.append('user', userFilter); |
||||
|
if (actionFilter) params.append('action', actionFilter); |
||||
|
if (entityTypeFilter) params.append('entity_type', entityTypeFilter); |
||||
|
if (dateFromFilter) params.append('date_from', dateFromFilter); |
||||
|
if (dateToFilter) params.append('date_to', dateToFilter); |
||||
|
|
||||
|
// Open export URL in new window |
||||
|
window.open(`/logs/export?${params.toString()}`, '_blank'); |
||||
|
} |
||||
|
|
||||
|
// Close modal on Escape key |
||||
|
document.addEventListener('keydown', function(event) { |
||||
|
if (event.key === 'Escape') { |
||||
|
const activeModals = document.querySelectorAll('.modal.is-active'); |
||||
|
activeModals.forEach(modal => modal.classList.remove('is-active')); |
||||
|
} |
||||
|
}); |
||||
|
</script> |
||||
|
{% endblock %} |
||||
Loading…
Reference in new issue