858 lines
No EOL
28 KiB
JavaScript
858 lines
No EOL
28 KiB
JavaScript
/**
|
||
* Credit Card Tracker
|
||
* Tracks reward categories, spending limits, and payment history for credit cards
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// DOM Elements
|
||
const cardsContainer = document.getElementById('cards-container');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const searchInput = document.getElementById('search-input');
|
||
const addCardBtn = document.getElementById('add-card-btn');
|
||
const addFirstCardBtn = document.getElementById('add-first-card-btn');
|
||
const cardModal = document.getElementById('card-modal');
|
||
const closeModalBtn = document.getElementById('close-modal');
|
||
const cardForm = document.getElementById('card-form');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const cardIdInput = document.getElementById('card-id');
|
||
const addCategoryBtn = document.getElementById('add-category-btn');
|
||
const categoriesContainer = document.getElementById('categories-container');
|
||
|
||
// Payment Modal Elements
|
||
const paymentModal = document.getElementById('payment-modal');
|
||
const closePaymentModalBtn = document.getElementById('close-payment-modal');
|
||
const paymentForm = document.getElementById('payment-form');
|
||
const paymentCardId = document.getElementById('payment-card-id');
|
||
const paymentCardName = document.getElementById('payment-card-name');
|
||
const paymentCategory = document.getElementById('payment-category');
|
||
const paymentAmount = document.getElementById('payment-amount');
|
||
const paymentDate = document.getElementById('payment-date');
|
||
|
||
// Create Payment History Modal - Will be added to DOM later
|
||
const paymentHistoryModal = document.createElement('div');
|
||
paymentHistoryModal.className = 'modal';
|
||
paymentHistoryModal.id = 'payment-history-modal';
|
||
|
||
// Initialize cards from localStorage
|
||
let cards = loadCards();
|
||
|
||
// Display cards or empty state
|
||
renderCards();
|
||
|
||
// Set today's date as default for payment date
|
||
paymentDate.valueAsDate = new Date();
|
||
|
||
// Event Listeners
|
||
addCardBtn.addEventListener('click', () => openAddCardModal());
|
||
addFirstCardBtn.addEventListener('click', () => openAddCardModal());
|
||
closeModalBtn.addEventListener('click', () => closeModal(cardModal));
|
||
closePaymentModalBtn.addEventListener('click', () => closeModal(paymentModal));
|
||
cardForm.addEventListener('submit', handleCardFormSubmit);
|
||
addCategoryBtn.addEventListener('click', () => addCategoryField('', '', ''));
|
||
paymentForm.addEventListener('submit', handlePaymentFormSubmit);
|
||
searchInput.addEventListener('input', handleSearch);
|
||
|
||
// Global event delegation for dynamically added card buttons
|
||
cardsContainer.addEventListener('click', handleCardActions);
|
||
|
||
// Check for monthly resets on page load
|
||
checkMonthlyResets();
|
||
|
||
// Initialize payment history modal
|
||
initPaymentHistoryModal();
|
||
|
||
/**
|
||
* Initialize payment history modal
|
||
*/
|
||
function initPaymentHistoryModal() {
|
||
paymentHistoryModal.innerHTML = `
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Payment History</h2>
|
||
<button class="close-modal" id="close-history-modal">×</button>
|
||
</div>
|
||
<div id="payment-history-container">
|
||
<!-- Payment history will be loaded here -->
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(paymentHistoryModal);
|
||
|
||
// Add event listener to close button
|
||
document.getElementById('close-history-modal').addEventListener('click', () => {
|
||
closeModal(paymentHistoryModal);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle card action buttons via event delegation
|
||
*/
|
||
function handleCardActions(e) {
|
||
const target = e.target;
|
||
|
||
// Find closest card element
|
||
const cardElement = target.closest('.credit-card');
|
||
if (!cardElement) return;
|
||
|
||
const cardId = cardElement.dataset.id;
|
||
const card = cards.find(c => c.id === cardId);
|
||
if (!card) return;
|
||
|
||
// Payment button
|
||
if (target.closest('.payment-btn')) {
|
||
e.stopPropagation();
|
||
openPaymentModal(card);
|
||
return;
|
||
}
|
||
|
||
// Edit button
|
||
if (target.closest('.edit-btn')) {
|
||
e.stopPropagation();
|
||
openEditCardModal(card);
|
||
return;
|
||
}
|
||
|
||
// Delete button
|
||
if (target.closest('.delete-btn')) {
|
||
e.stopPropagation();
|
||
deleteCard(cardId);
|
||
return;
|
||
}
|
||
|
||
// View payment history (clicking on a category item)
|
||
const categoryItem = target.closest('.category-item');
|
||
if (categoryItem && categoryItem.dataset.categoryName) {
|
||
const categoryName = categoryItem.dataset.categoryName;
|
||
const category = card.categories.find(c => c.name === categoryName);
|
||
if (category) {
|
||
openPaymentHistoryModal(card, category);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load cards from localStorage
|
||
*/
|
||
function loadCards() {
|
||
const storedCards = localStorage.getItem('creditCards');
|
||
return storedCards ? JSON.parse(storedCards) : [];
|
||
}
|
||
|
||
/**
|
||
* Save cards to localStorage
|
||
*/
|
||
function saveCards() {
|
||
localStorage.setItem('creditCards', JSON.stringify(cards));
|
||
}
|
||
|
||
/**
|
||
* Render all cards or empty state
|
||
*/
|
||
function renderCards() {
|
||
// Clear cards container except for the empty state
|
||
Array.from(cardsContainer.children).forEach(child => {
|
||
if (!child.classList.contains('empty-state')) {
|
||
child.remove();
|
||
}
|
||
});
|
||
|
||
// Show empty state if no cards
|
||
if (cards.length === 0) {
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Hide empty state and render cards
|
||
emptyState.style.display = 'none';
|
||
|
||
// Filter cards if search input has value
|
||
let filteredCards = cards;
|
||
const searchTerm = searchInput.value.toLowerCase().trim();
|
||
|
||
if (searchTerm) {
|
||
filteredCards = cards.filter(card => {
|
||
const nameMatch = card.name.toLowerCase().includes(searchTerm);
|
||
const bankMatch = card.bank.toLowerCase().includes(searchTerm);
|
||
const categoryMatch = card.categories.some(cat =>
|
||
cat.name.toLowerCase().includes(searchTerm)
|
||
);
|
||
|
||
return nameMatch || bankMatch || categoryMatch;
|
||
});
|
||
}
|
||
|
||
// Render filtered cards
|
||
filteredCards.forEach(card => {
|
||
const cardElement = createCardElement(card);
|
||
cardsContainer.insertBefore(cardElement, emptyState);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create a card element
|
||
*/
|
||
function createCardElement(card) {
|
||
const cardElement = document.createElement('div');
|
||
cardElement.className = 'credit-card';
|
||
cardElement.dataset.id = card.id;
|
||
|
||
// Calculate days until cycle resets
|
||
const today = new Date();
|
||
const currentDay = today.getDate();
|
||
const cycleDay = parseInt(card.statementDate);
|
||
|
||
let daysUntilReset;
|
||
if (currentDay === cycleDay) {
|
||
daysUntilReset = 0;
|
||
} else if (currentDay < cycleDay) {
|
||
daysUntilReset = cycleDay - currentDay;
|
||
} else {
|
||
// Calculate days until next month's cycle date
|
||
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
|
||
daysUntilReset = (lastDayOfMonth - currentDay) + cycleDay;
|
||
}
|
||
|
||
// Create card HTML
|
||
cardElement.innerHTML = `
|
||
<div class="card-header">
|
||
<div class="card-details">
|
||
<h2 class="card-title">${card.name}</h2>
|
||
<div class="card-bank">${card.bank}${card.lastDigits ? ` •••• ${card.lastDigits}` : ''}</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
<button type="button" class="action-btn payment-btn" title="Record Payment" aria-label="Record Payment">💰</button>
|
||
<button type="button" class="action-btn edit-btn" title="Edit Card" aria-label="Edit Card">✏️</button>
|
||
<button type="button" class="action-btn delete-btn" title="Delete Card" aria-label="Delete Card">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-info">
|
||
<div class="info-row">
|
||
<span class="info-label">Statement Cycle:</span>
|
||
<span class="info-value">Day ${card.statementDate}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="info-label">Cycle Resets:</span>
|
||
<span class="info-value">${daysUntilReset === 0 ? 'Today' : `In ${daysUntilReset} day${daysUntilReset !== 1 ? 's' : ''}`}</span>
|
||
</div>
|
||
${card.expiryDate ? `
|
||
<div class="info-row">
|
||
<span class="info-label">Expires:</span>
|
||
<span class="info-value">${formatExpiryDate(card.expiryDate)}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="rewards-categories">
|
||
${card.categories.map(category => {
|
||
const payments = category.payments; // Assumed to be an array now
|
||
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
|
||
const hasLimit = category.limit > 0;
|
||
const percentUsed = hasLimit ? (spent / category.limit) * 100 : 0;
|
||
const isNearLimit = percentUsed >= 75 && percentUsed < 100;
|
||
const isAtLimit = percentUsed >= 100;
|
||
const hasPayments = payments.length > 0;
|
||
|
||
// Calculate cash back amounts
|
||
const cashbackEarned = (spent * category.rate / 100).toFixed(2);
|
||
const maxCashback = hasLimit ? (category.limit * category.rate / 100).toFixed(2) : 0;
|
||
const cashbackDisplay = hasLimit ? ` ($${cashbackEarned}/$${maxCashback})` : ` ($${cashbackEarned})`;
|
||
|
||
return `
|
||
<div class="category-item ${hasPayments ? 'has-payments' : ''}" data-category-name="${category.name}">
|
||
<div class="category-header">
|
||
<span class="category-tag">${category.name}: ${category.rate}%</span>
|
||
${hasLimit ? `
|
||
<span class="info-value">$${spent.toFixed(2)} / $${category.limit.toFixed(2)}${cashbackDisplay}</span>
|
||
` : `<span class="info-value">$${spent.toFixed(2)}${cashbackDisplay}</span>`}
|
||
</div>
|
||
${hasLimit ? `
|
||
<div class="progress-bar ${isNearLimit ? 'near-limit' : ''} ${isAtLimit ? 'at-limit' : ''}">
|
||
<div class="progress-fill" style="width: ${Math.min(percentUsed, 100)}%"></div>
|
||
</div>
|
||
` : ''}
|
||
${hasPayments ? `<div class="view-payments-link">View ${payments.length} payment${payments.length !== 1 ? 's' : ''}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
return cardElement;
|
||
}
|
||
|
||
/**
|
||
* Format expiry date (YYYY-MM to MM/YYYY)
|
||
*/
|
||
function formatExpiryDate(dateString) {
|
||
const [year, month] = dateString.split('-');
|
||
return `${month}/${year}`;
|
||
}
|
||
|
||
/**
|
||
* Format a date for display (YYYY-MM-DD to MM/DD/YYYY)
|
||
*/
|
||
function formatDate(dateString) {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('en-US', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
year: 'numeric'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Open modal to add a new card
|
||
*/
|
||
function openAddCardModal() {
|
||
// Reset form
|
||
cardForm.reset();
|
||
cardIdInput.value = '';
|
||
modalTitle.textContent = 'Add New Card';
|
||
|
||
// Clear existing category fields and add one empty one
|
||
clearCategoryFields();
|
||
|
||
// Open modal
|
||
openModal(cardModal);
|
||
}
|
||
|
||
/**
|
||
* Open modal to edit an existing card
|
||
*/
|
||
function openEditCardModal(card) {
|
||
// Set form values
|
||
cardForm.reset();
|
||
cardIdInput.value = card.id;
|
||
document.getElementById('card-name').value = card.name;
|
||
document.getElementById('card-bank').value = card.bank;
|
||
document.getElementById('last-digits').value = card.lastDigits || '';
|
||
document.getElementById('expiry-date').value = card.expiryDate || '';
|
||
document.getElementById('statement-date').value = card.statementDate;
|
||
|
||
modalTitle.textContent = 'Edit Card';
|
||
|
||
// Clear existing category fields
|
||
clearCategoryFields();
|
||
|
||
// Add category fields for existing categories
|
||
if (card.categories.length === 0) {
|
||
} else {
|
||
card.categories.forEach(category => {
|
||
addCategoryField(category.name, category.rate, category.limit);
|
||
});
|
||
}
|
||
|
||
// Open modal
|
||
openModal(cardModal);
|
||
}
|
||
|
||
/**
|
||
* Open modal to record a payment
|
||
*/
|
||
function openPaymentModal(card) {
|
||
paymentCardId.value = card.id;
|
||
paymentCardName.textContent = card.name;
|
||
|
||
// Clear and populate category dropdown
|
||
paymentCategory.innerHTML = '';
|
||
card.categories.forEach(category => {
|
||
if (!category) return;
|
||
const option = document.createElement('option');
|
||
option.value = category.name;
|
||
option.textContent = `${category.name} (${category.rate}%)`;
|
||
|
||
// Add info about limit if one exists
|
||
if (category.limit > 0) {
|
||
const payments = category.payments; // Assumed to be an array now
|
||
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
|
||
const remaining = Math.max(0, category.limit - spent);
|
||
option.textContent += ` - $${remaining.toFixed(2)} remaining`;
|
||
}
|
||
|
||
paymentCategory.appendChild(option);
|
||
});
|
||
|
||
// Reset other fields
|
||
paymentAmount.value = '';
|
||
paymentDate.valueAsDate = new Date();
|
||
document.getElementById('payment-note').value = '';
|
||
|
||
// Set modal title and button text for new payment
|
||
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
|
||
paymentModal.querySelector('.save-btn').textContent = 'Record Payment';
|
||
|
||
// Open modal
|
||
openModal(paymentModal);
|
||
}
|
||
|
||
/**
|
||
* Open payment history modal for a category
|
||
*/
|
||
function openPaymentHistoryModal(card, category) {
|
||
if (!card || !category) return;
|
||
const container = document.getElementById('payment-history-container');
|
||
const modalTitle = paymentHistoryModal.querySelector('.modal-title');
|
||
|
||
modalTitle.textContent = `${card.name} - ${category.name} Payments`;
|
||
container.innerHTML = ''; // Clear previous content
|
||
|
||
const payments = category.payments; // Assumed to be an array now
|
||
|
||
if (payments.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<h3>No payment history</h3>
|
||
<p>No payments have been recorded for this category yet.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
const sortedPayments = [...payments].sort((a, b) =>
|
||
new Date(b.date) - new Date(a.date)
|
||
);
|
||
|
||
const historyHtml = `
|
||
<div class="payment-history-list">
|
||
<div class="payment-history-header">
|
||
<span>Date</span>
|
||
<span>Amount</span>
|
||
<span>Note</span>
|
||
<span>Actions</span>
|
||
</div>
|
||
${sortedPayments.map(payment => `
|
||
<div class="payment-history-item" data-payment-id="${payment.id}">
|
||
<span class="payment-date">${formatDate(payment.date)}</span>
|
||
<span class="payment-amount">$${parseFloat(payment.amount).toFixed(2)}</span>
|
||
<span class="payment-note">${payment.note || '-'}</span>
|
||
<div class="payment-actions">
|
||
<button type="button" class="action-btn edit-payment-btn" title="Edit Payment" aria-label="Edit Payment">✏️</button>
|
||
<button type="button" class="action-btn delete-payment-btn" title="Delete Payment" aria-label="Delete Payment">🗑️</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
container.innerHTML = historyHtml;
|
||
|
||
// Add event listeners for edit and delete payment buttons
|
||
container.querySelectorAll('.edit-payment-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
|
||
const payment = payments.find(p => p.id === paymentId);
|
||
if (payment) {
|
||
editPayment(card.id, category.name, payment);
|
||
}
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('.delete-payment-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
|
||
const payment = payments.find(p => p.id === paymentId);
|
||
if (payment) {
|
||
deletePayment(card.id, category.name, payment.id);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Open the modal
|
||
openModal(paymentHistoryModal);
|
||
}
|
||
|
||
/**
|
||
* Edit a payment
|
||
*/
|
||
function editPayment(cardId, categoryName, payment) {
|
||
// Set up payment modal for editing
|
||
paymentCardId.value = cardId;
|
||
const card = cards.find(c => c.id === cardId);
|
||
if (!card) return;
|
||
|
||
paymentCardName.textContent = card.name;
|
||
|
||
// Populate category dropdown (with only the selected category)
|
||
paymentCategory.innerHTML = '';
|
||
const category = card.categories.find(c => c.name === categoryName);
|
||
if (!category) return;
|
||
|
||
const option = document.createElement('option');
|
||
option.value = category.name;
|
||
option.textContent = `${category.name} (${category.rate}%)`;
|
||
paymentCategory.appendChild(option);
|
||
|
||
// Set payment details
|
||
paymentAmount.value = payment.amount;
|
||
paymentDate.value = payment.date;
|
||
document.getElementById('payment-note').value = payment.note || '';
|
||
|
||
// Add payment ID to the form
|
||
const paymentIdInput = document.getElementById('payment-id') || document.createElement('input');
|
||
paymentIdInput.type = 'hidden';
|
||
paymentIdInput.id = 'payment-id';
|
||
paymentIdInput.value = payment.id;
|
||
if (!document.getElementById('payment-id')) {
|
||
paymentForm.appendChild(paymentIdInput);
|
||
}
|
||
|
||
// Change modal title and button text
|
||
paymentModal.querySelector('.modal-title').textContent = 'Edit Payment';
|
||
paymentModal.querySelector('.save-btn').textContent = 'Update Payment';
|
||
|
||
// Close history modal and open payment modal with a slight delay for better transition
|
||
closeModal(paymentHistoryModal);
|
||
setTimeout(() => {
|
||
openModal(paymentModal);
|
||
}, 300);
|
||
}
|
||
|
||
/**
|
||
* Delete a payment
|
||
*/
|
||
function deletePayment(cardId, categoryName, paymentId) {
|
||
if (!confirm('Are you sure you want to delete this payment?')) return;
|
||
|
||
const cardIndex = cards.findIndex(c => c.id === cardId);
|
||
if (cardIndex === -1) return;
|
||
|
||
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
|
||
if (categoryIndex === -1) return;
|
||
|
||
// Remove the payment from the array
|
||
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
|
||
const paymentIndex = payments.findIndex(p => p.id === paymentId);
|
||
if (paymentIndex === -1) return;
|
||
|
||
// Get payment amount for confirmation
|
||
const amount = payments[paymentIndex].amount;
|
||
|
||
// Remove the payment
|
||
payments.splice(paymentIndex, 1);
|
||
|
||
// Save changes
|
||
saveCards();
|
||
|
||
// Close the history modal
|
||
closeModal(paymentHistoryModal);
|
||
|
||
// Show confirmation toast
|
||
showToast(`Payment of $${amount.toFixed(2)} has been deleted`);
|
||
|
||
// Re-render cards
|
||
renderCards();
|
||
}
|
||
|
||
/**
|
||
* Open a modal
|
||
*/
|
||
function openModal(modal) {
|
||
modal.classList.add('active');
|
||
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||
|
||
// Focus first input in modal for better accessibility
|
||
setTimeout(() => {
|
||
const firstInput = modal.querySelector('input:not([type="hidden"])');
|
||
if (firstInput) firstInput.focus();
|
||
}, 100);
|
||
}
|
||
|
||
/**
|
||
* Close a modal
|
||
*/
|
||
function closeModal(modal) {
|
||
modal.classList.remove('active');
|
||
document.body.style.overflow = ''; // Restore scrolling
|
||
}
|
||
|
||
/**
|
||
* Clear all category fields
|
||
*/
|
||
function clearCategoryFields() {
|
||
categoriesContainer.innerHTML = '';
|
||
}
|
||
|
||
/**
|
||
* Add a category field to the form
|
||
*/
|
||
function addCategoryField(name = '', rate = '', limit = '') {
|
||
const categoryField = document.createElement('div');
|
||
categoryField.className = 'category-inputs';
|
||
categoryField.innerHTML = `
|
||
<input type="text" class="form-input category-name" placeholder="Category (e.g. Dining)" value="${name}">
|
||
<input type="number" class="form-input category-rate" placeholder="Rate %" step="0.1" min="0" value="${rate}">
|
||
<input type="number" class="form-input category-limit" placeholder="Limit $" step="0.01" min="0" value="${limit}">
|
||
<button type="button" class="action-btn delete-btn remove-category" title="Remove Category" aria-label="Remove Category">×</button>
|
||
`;
|
||
|
||
// Add event listener to remove button
|
||
categoryField.querySelector('.remove-category').addEventListener('click', function() {
|
||
// Allow removing the last category field
|
||
categoryField.remove();
|
||
});
|
||
|
||
categoriesContainer.appendChild(categoryField);
|
||
}
|
||
|
||
/**
|
||
* Handle card form submission
|
||
*/
|
||
function handleCardFormSubmit(e) {
|
||
e.preventDefault();
|
||
|
||
// Get form values
|
||
const cardId = cardIdInput.value || generateId();
|
||
const isEditing = !!cardIdInput.value;
|
||
const name = document.getElementById('card-name').value;
|
||
const bank = document.getElementById('card-bank').value;
|
||
const lastDigits = document.getElementById('last-digits').value;
|
||
const expiryDate = document.getElementById('expiry-date').value;
|
||
const statementDate = document.getElementById('statement-date').value;
|
||
|
||
// Get categories
|
||
const categories = [];
|
||
const categoryInputs = categoriesContainer.querySelectorAll('.category-inputs');
|
||
|
||
categoryInputs.forEach(input => {
|
||
const catName = input.querySelector('.category-name').value.trim();
|
||
const catRate = input.querySelector('.category-rate').value.trim();
|
||
const catLimit = input.querySelector('.category-limit').value.trim();
|
||
|
||
// Only add this category if any reward detail is provided
|
||
if (!catName && !catRate && !catLimit) {
|
||
return;
|
||
}
|
||
|
||
// Determine payments: Use existing if editing and found, otherwise default to empty array
|
||
let payments = [];
|
||
const existingCardData = isEditing ? cards.find(c => c.id === cardId) : null;
|
||
if (isEditing && existingCardData && existingCardData.categories) {
|
||
const existingCategoryData = existingCardData.categories.find(c => c.name === catName);
|
||
if (existingCategoryData && Array.isArray(existingCategoryData.payments)) {
|
||
payments = existingCategoryData.payments;
|
||
}
|
||
}
|
||
|
||
categories.push({
|
||
name: catName,
|
||
rate: catRate ? parseFloat(catRate) : 0,
|
||
limit: catLimit ? parseFloat(catLimit) : null,
|
||
payments: payments // Ensured to be an array
|
||
});
|
||
});
|
||
|
||
// Create card object
|
||
const card = {
|
||
id: cardId,
|
||
name,
|
||
bank,
|
||
lastDigits,
|
||
expiryDate,
|
||
statementDate,
|
||
categories, // categories array now guaranteed to have .payments arrays
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
|
||
// Update or add card
|
||
const existingCardIndex = isEditing ? cards.findIndex(c => c.id === cardId) : -1;
|
||
|
||
if (existingCardIndex !== -1) {
|
||
// Preserve archived payments if editing
|
||
card.archivedPayments = cards[existingCardIndex].archivedPayments;
|
||
cards[existingCardIndex] = card;
|
||
} else {
|
||
cards.push(card);
|
||
}
|
||
|
||
// Save and render cards
|
||
saveCards();
|
||
renderCards();
|
||
|
||
// Show feedback toast
|
||
showToast(isEditing ? 'Card updated successfully' : 'Card added successfully');
|
||
|
||
// Close modal
|
||
closeModal(cardModal);
|
||
}
|
||
|
||
/**
|
||
* Handle payment form submission
|
||
*/
|
||
function handlePaymentFormSubmit(e) {
|
||
e.preventDefault();
|
||
|
||
// Get form values
|
||
const cardId = paymentCardId.value;
|
||
const categoryName = paymentCategory.value;
|
||
const amount = parseFloat(paymentAmount.value);
|
||
const date = paymentDate.value;
|
||
const note = document.getElementById('payment-note').value;
|
||
const paymentId = document.getElementById('payment-id')?.value;
|
||
|
||
// Find card and category
|
||
const cardIndex = cards.findIndex(c => c.id === cardId);
|
||
if (cardIndex === -1) return;
|
||
|
||
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
|
||
if (categoryIndex === -1) return;
|
||
|
||
// Check if editing existing payment or adding new one
|
||
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
|
||
|
||
if (paymentId) {
|
||
// Find the payment
|
||
const paymentIndex = payments.findIndex(p => p.id === paymentId);
|
||
if (paymentIndex !== -1) {
|
||
// Update payment
|
||
payments[paymentIndex] = {
|
||
id: paymentId,
|
||
amount,
|
||
date,
|
||
note,
|
||
createdAt: payments[paymentIndex].createdAt, // Keep original creation date
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
|
||
// Show toast
|
||
showToast('Payment updated successfully');
|
||
}
|
||
} else {
|
||
// Add new payment
|
||
const payment = {
|
||
id: generateId(),
|
||
amount,
|
||
date,
|
||
note,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
|
||
payments.push(payment);
|
||
|
||
// Show toast
|
||
showToast(`Payment of $${amount.toFixed(2)} recorded`);
|
||
}
|
||
|
||
// Save and render cards
|
||
saveCards();
|
||
renderCards();
|
||
|
||
// Remove payment ID if it exists
|
||
if (document.getElementById('payment-id')) {
|
||
document.getElementById('payment-id').remove();
|
||
}
|
||
|
||
// Reset modal title
|
||
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
|
||
|
||
// Close modal
|
||
closeModal(paymentModal);
|
||
}
|
||
|
||
/**
|
||
* Delete a card
|
||
*/
|
||
function deleteCard(cardId) {
|
||
if (!confirm('Are you sure you want to delete this card?')) return;
|
||
|
||
const cardName = cards.find(card => card.id === cardId)?.name || 'Card';
|
||
cards = cards.filter(card => card.id !== cardId);
|
||
saveCards();
|
||
renderCards();
|
||
|
||
// Show feedback toast
|
||
showToast(`${cardName} has been deleted`);
|
||
}
|
||
|
||
/**
|
||
* Show a toast notification
|
||
*/
|
||
function showToast(message) {
|
||
// Remove existing toast if any
|
||
const existingToast = document.querySelector('.toast');
|
||
if (existingToast) {
|
||
existingToast.remove();
|
||
}
|
||
|
||
// Create toast element
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast';
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
|
||
// Trigger animation
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
|
||
// Auto remove after 3 seconds
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* Handle search
|
||
*/
|
||
function handleSearch() {
|
||
renderCards();
|
||
}
|
||
|
||
/**
|
||
* Generate a unique ID
|
||
*/
|
||
function generateId() {
|
||
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||
}
|
||
|
||
/**
|
||
* Check for monthly cycle resets
|
||
*/
|
||
function checkMonthlyResets() {
|
||
const today = new Date();
|
||
const lastCheck = localStorage.getItem('lastCycleCheck');
|
||
|
||
// If we haven't checked today
|
||
if (!lastCheck || new Date(lastCheck).toDateString() !== today.toDateString()) {
|
||
const currentDay = today.getDate();
|
||
|
||
// Check each card for cycle reset
|
||
cards.forEach(card => {
|
||
const cycleDay = parseInt(card.statementDate);
|
||
|
||
// If today is the cycle reset day
|
||
if (currentDay === cycleDay) {
|
||
// Mark payments as from previous cycle
|
||
card.categories.forEach(category => {
|
||
// Only archive if there are payments
|
||
if (category.payments.length > 0) {
|
||
// Create an archive if none exists
|
||
if (!card.archivedPayments) {
|
||
card.archivedPayments = [];
|
||
}
|
||
|
||
// Archive current cycle payments
|
||
const cycleData = {
|
||
date: today.toISOString(),
|
||
categories: [{
|
||
name: category.name,
|
||
rate: category.rate,
|
||
payments: [...category.payments]
|
||
}]
|
||
};
|
||
|
||
// Add to archived payments
|
||
card.archivedPayments.push(cycleData);
|
||
|
||
// Clear current payments
|
||
category.payments = [];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Save changes
|
||
saveCards();
|
||
|
||
// Update last check date
|
||
localStorage.setItem('lastCycleCheck', today.toISOString());
|
||
}
|
||
}
|
||
});
|