mirror of
https://github.com/DumbWareio/DumbBudget.git
synced 2026-02-20 01:00:57 +08:00
1041 lines
39 KiB
JavaScript
1041 lines
39 KiB
JavaScript
// Theme toggle functionality
|
|
function getBaseUrl() {
|
|
// First try to get it from the server-provided meta tag
|
|
const metaBaseUrl = document.querySelector('meta[name="base-url"]')?.content;
|
|
if (metaBaseUrl) return metaBaseUrl;
|
|
|
|
// Fallback to window.location.origin
|
|
return window.location.origin;
|
|
}
|
|
|
|
function initThemeToggle() {
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
|
|
|
// Set initial theme based on system preference
|
|
if (localStorage.getItem('theme') === null) {
|
|
document.documentElement.setAttribute('data-theme', prefersDark.matches ? 'dark' : 'light');
|
|
} else {
|
|
document.documentElement.setAttribute('data-theme', localStorage.getItem('theme'));
|
|
}
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
|
|
|
document.documentElement.setAttribute('data-theme', newTheme);
|
|
document.documentElement.style.setProperty('--is-dark', newTheme === 'dark' ? '1' : '0');
|
|
localStorage.setItem('theme', newTheme);
|
|
});
|
|
}
|
|
|
|
// Debug logging
|
|
function debugLog(...args) {
|
|
if (window.appConfig?.debug) {
|
|
console.log('[DEBUG]', ...args);
|
|
}
|
|
}
|
|
|
|
// Helper function to join paths with base path
|
|
function joinPath(path) {
|
|
const basePath = window.appConfig?.basePath || '';
|
|
debugLog('joinPath input:', path);
|
|
debugLog('basePath:', basePath);
|
|
|
|
// If path starts with http(s), return as is
|
|
if (path.match(/^https?:\/\//)) {
|
|
debugLog('Absolute URL detected, returning as is:', path);
|
|
return path;
|
|
}
|
|
|
|
// Remove any leading slash from path and trailing slash from basePath
|
|
const cleanPath = path.replace(/^\/+/, '');
|
|
const cleanBase = basePath.replace(/\/+$/, '');
|
|
|
|
// Join with single slash
|
|
const result = cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath;
|
|
debugLog('joinPath result:', result);
|
|
return result;
|
|
}
|
|
|
|
// PIN input functionality
|
|
function setupPinInputs() {
|
|
const form = document.getElementById('pinForm');
|
|
if (!form) return; // Only run on login page
|
|
|
|
debugLog('Setting up PIN inputs');
|
|
// Fetch PIN length from server
|
|
|
|
fetch(joinPath('pin-length'))
|
|
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const pinLength = data.length;
|
|
debugLog('PIN length:', pinLength);
|
|
const container = document.querySelector('.pin-input-container');
|
|
|
|
// Create PIN input fields
|
|
for (let i = 0; i < pinLength; i++) {
|
|
const input = document.createElement('input');
|
|
input.type = 'password';
|
|
input.maxLength = 1;
|
|
input.className = 'pin-input';
|
|
input.setAttribute('inputmode', 'numeric');
|
|
input.pattern = '[0-9]*';
|
|
input.setAttribute('autocomplete', 'off');
|
|
container.appendChild(input);
|
|
}
|
|
|
|
// Handle input behavior
|
|
const inputs = container.querySelectorAll('.pin-input');
|
|
|
|
// Focus first input immediately
|
|
if (inputs.length > 0) {
|
|
inputs[0].focus();
|
|
}
|
|
|
|
inputs.forEach((input, index) => {
|
|
input.addEventListener('input', (e) => {
|
|
// Only allow numbers
|
|
e.target.value = e.target.value.replace(/[^0-9]/g, '');
|
|
|
|
if (e.target.value) {
|
|
e.target.classList.add('has-value');
|
|
if (index < inputs.length - 1) {
|
|
inputs[index + 1].focus();
|
|
} else {
|
|
// Last digit entered, submit the form
|
|
const pin = Array.from(inputs).map(input => input.value).join('');
|
|
submitPin(pin, inputs);
|
|
}
|
|
} else {
|
|
e.target.classList.remove('has-value');
|
|
}
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Backspace' && !e.target.value && index > 0) {
|
|
inputs[index - 1].focus();
|
|
}
|
|
});
|
|
|
|
// Prevent paste of multiple characters
|
|
input.addEventListener('paste', (e) => {
|
|
e.preventDefault();
|
|
const pastedData = e.clipboardData.getData('text');
|
|
const numbers = pastedData.match(/\d/g);
|
|
|
|
if (numbers) {
|
|
numbers.forEach((num, i) => {
|
|
if (inputs[index + i]) {
|
|
inputs[index + i].value = num;
|
|
inputs[index + i].classList.add('has-value');
|
|
if (index + i + 1 < inputs.length) {
|
|
inputs[index + i + 1].focus();
|
|
} else {
|
|
// If paste fills all inputs, submit the form
|
|
const pin = Array.from(inputs).map(input => input.value).join('');
|
|
submitPin(pin, inputs);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Handle PIN submission with debug logging
|
|
function submitPin(pin, inputs) {
|
|
debugLog('Submitting PIN');
|
|
const errorElement = document.querySelector('.pin-error');
|
|
|
|
|
|
fetch(joinPath('verify-pin'), {
|
|
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ pin })
|
|
})
|
|
.then(async response => {
|
|
const data = await response.json();
|
|
debugLog('PIN verification response:', response.status);
|
|
|
|
if (response.ok) {
|
|
debugLog('PIN verified, redirecting to home');
|
|
window.location.pathname = joinPath('/');
|
|
} else if (response.status === 429) {
|
|
debugLog('Account locked out');
|
|
// Handle lockout
|
|
errorElement.textContent = data.error;
|
|
errorElement.setAttribute('aria-hidden', 'false');
|
|
inputs.forEach(input => {
|
|
input.value = '';
|
|
input.classList.remove('has-value');
|
|
input.disabled = true;
|
|
});
|
|
} else {
|
|
// Handle invalid PIN
|
|
const message = data.attemptsLeft > 0
|
|
? `Incorrect PIN. ${data.attemptsLeft} attempts remaining.`
|
|
: 'Incorrect PIN. Last attempt before lockout.';
|
|
|
|
errorElement.textContent = message;
|
|
errorElement.setAttribute('aria-hidden', 'false');
|
|
inputs.forEach(input => {
|
|
input.value = '';
|
|
input.classList.remove('has-value');
|
|
});
|
|
inputs[0].focus();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
debugLog('PIN verification error:', error);
|
|
errorElement.textContent = 'An error occurred. Please try again.';
|
|
errorElement.setAttribute('aria-hidden', 'false');
|
|
});
|
|
}
|
|
|
|
// Supported currencies list
|
|
const SUPPORTED_CURRENCIES = {
|
|
USD: { locale: 'en-US', symbol: '$' },
|
|
EUR: { locale: 'de-DE', symbol: '€' },
|
|
GBP: { locale: 'en-GB', symbol: '£' },
|
|
JPY: { locale: 'ja-JP', symbol: '¥' },
|
|
AUD: { locale: 'en-AU', symbol: 'A$' },
|
|
CAD: { locale: 'en-CA', symbol: 'C$' },
|
|
CHF: { locale: 'de-CH', symbol: 'CHF' },
|
|
CNY: { locale: 'zh-CN', symbol: '¥' },
|
|
HKD: { locale: 'zh-HK', symbol: 'HK$' },
|
|
NZD: { locale: 'en-NZ', symbol: 'NZ$' },
|
|
MXN: { locale: 'es-MX', symbol: '$' },
|
|
RUB: { locale: 'ru-RU', symbol: '₽' },
|
|
SGD: { locale: 'en-SG', symbol: 'S$' },
|
|
KRW: { locale: 'ko-KR', symbol: '₩' },
|
|
INR: { locale: 'en-IN', symbol: '₹' },
|
|
BRL: { locale: 'pt-BR', symbol: 'R$' },
|
|
ZAR: { locale: 'en-ZA', symbol: 'R' },
|
|
TRY: { locale: 'tr-TR', symbol: '₺' },
|
|
PLN: { locale: 'pl-PL', symbol: 'zł' },
|
|
SEK: { locale: 'sv-SE', symbol: 'kr' },
|
|
NOK: { locale: 'nb-NO', symbol: 'kr' },
|
|
DKK: { locale: 'da-DK', symbol: 'kr' }
|
|
};
|
|
|
|
let currentCurrency = 'USD'; // Default currency
|
|
|
|
// Fetch current currency from server
|
|
async function fetchCurrentCurrency() {
|
|
try {
|
|
debugLog('Fetching current currency');
|
|
const response = await fetch(joinPath('api/settings/currency'), fetchConfig);
|
|
await handleFetchResponse(response);
|
|
const data = await response.json();
|
|
currentCurrency = data.currency;
|
|
debugLog('Current currency set to:', currentCurrency);
|
|
} catch (error) {
|
|
console.error('Error fetching currency:', error);
|
|
debugLog('Falling back to USD');
|
|
// Fallback to USD if there's an error
|
|
currentCurrency = 'USD';
|
|
}
|
|
}
|
|
|
|
// Update the formatCurrency function to use the current currency
|
|
const formatCurrency = (amount) => {
|
|
const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD;
|
|
return new Intl.NumberFormat(currencyInfo.locale, {
|
|
style: 'currency',
|
|
currency: currentCurrency
|
|
}).format(amount);
|
|
};
|
|
|
|
let currentDate = new Date();
|
|
|
|
// Shared fetch configuration with debug logging
|
|
const fetchConfig = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
// Handle session errors - only for main app, not login
|
|
async function handleFetchResponse(response) {
|
|
debugLog('Fetch response:', response.status, response.url);
|
|
|
|
// If we're already on the login page, don't redirect
|
|
if (window.location.pathname.includes('login')) {
|
|
return response;
|
|
}
|
|
|
|
// Handle unauthorized responses
|
|
if (response.status === 401) {
|
|
debugLog('Unauthorized, redirecting to login');
|
|
window.location.href = joinPath('login');
|
|
return null;
|
|
}
|
|
|
|
// Handle other error responses
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
// Check content type
|
|
const contentType = response.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
debugLog('Response is not JSON, session likely expired');
|
|
window.location.href = joinPath('login');
|
|
return null;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Add currentFilter variable at the top with other shared variables
|
|
let currentFilter = null; // null = show all, 'income' = only income, 'expense' = only expenses
|
|
|
|
// Add at the top with other variables
|
|
let editingTransactionId = null;
|
|
|
|
// Add at the top with other shared variables
|
|
let currentSortField = 'date';
|
|
let currentSortDirection = 'desc';
|
|
|
|
// Update loadTransactions function
|
|
async function loadTransactions() {
|
|
try {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const response = await fetch(joinPath(`api/transactions/range?start=${startDate}&end=${endDate}`), fetchConfig);
|
|
await handleFetchResponse(response);
|
|
const transactions = await response.json();
|
|
|
|
const transactionsList = document.getElementById('transactionsList');
|
|
let filteredTransactions = currentFilter
|
|
? transactions.filter(t => t.type === currentFilter)
|
|
: transactions;
|
|
|
|
// Sort transactions
|
|
filteredTransactions.sort((a, b) => {
|
|
if (currentSortField === 'date') {
|
|
// Use string comparison for dates to avoid timezone issues
|
|
return currentSortDirection === 'asc'
|
|
? a.date.localeCompare(b.date)
|
|
: b.date.localeCompare(a.date);
|
|
} else {
|
|
return currentSortDirection === 'asc' ? a.amount - b.amount : b.amount - a.amount;
|
|
}
|
|
});
|
|
|
|
transactionsList.innerHTML = filteredTransactions.map(transaction => {
|
|
// Split the date string and format as M/D/YYYY without timezone conversion
|
|
const [year, month, day] = transaction.date.split('-');
|
|
const formattedDate = `${parseInt(month)}/${parseInt(day)}/${year}`;
|
|
|
|
const isRecurring = transaction.isRecurringInstance || transaction.recurring;
|
|
|
|
return `
|
|
<div class="transaction-item ${isRecurring ? 'recurring-instance' : ''}" data-id="${transaction.id}" data-type="${transaction.type}">
|
|
<div class="transaction-content">
|
|
<div class="details">
|
|
<div class="description">${transaction.description}</div>
|
|
<div class="metadata">
|
|
${transaction.category ? `<span class="category">${transaction.category}</span>` : ''}
|
|
<span class="date">${formattedDate}</span>
|
|
${isRecurring ? `<span class="recurring-info">(Recurring)</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="transaction-amount ${transaction.type}">
|
|
${transaction.type === 'expense' ? '-' : ''}${formatCurrency(transaction.amount)}
|
|
</div>
|
|
</div>
|
|
<button class="delete-transaction" aria-label="Delete transaction">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 6h18"></path>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
|
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`}).join('');
|
|
|
|
// Add click handlers for editing and deleting
|
|
transactionsList.querySelectorAll('.transaction-item').forEach(item => {
|
|
const deleteBtn = item.querySelector('.delete-transaction');
|
|
const content = item.querySelector('.transaction-content');
|
|
const isRecurring = item.classList.contains('recurring-instance');
|
|
|
|
// Edit handler for all transactions
|
|
content.addEventListener('click', () => {
|
|
const id = item.dataset.id;
|
|
const type = item.dataset.type;
|
|
const isRecurring = item.classList.contains('recurring-instance');
|
|
|
|
// For recurring instances, get the parent transaction
|
|
let transaction = filteredTransactions.find(t => t.id === id);
|
|
if (isRecurring) {
|
|
const parentId = id.match(/^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+/)[0];
|
|
transaction = filteredTransactions.find(t => t.id === parentId) || transaction;
|
|
}
|
|
|
|
editTransaction(id, transaction, isRecurring);
|
|
});
|
|
|
|
// Delete handler
|
|
deleteBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const id = item.dataset.id;
|
|
const isRecurring = item.classList.contains('recurring-instance');
|
|
|
|
// For recurring instances, get the parent ID (the UUID part before the timestamp)
|
|
const transactionId = isRecurring ? id.match(/^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+/)[0] : id;
|
|
|
|
const message = isRecurring ?
|
|
'Are you sure you want to delete this recurring transaction? This will delete ALL instances of this transaction.' :
|
|
'Are you sure you want to delete this transaction?';
|
|
|
|
if (confirm(message)) {
|
|
try {
|
|
debugLog('Deleting transaction with ID:', transactionId);
|
|
const response = await fetch(joinPath(`api/transactions/${transactionId}`), {
|
|
...fetchConfig,
|
|
method: 'DELETE'
|
|
});
|
|
await handleFetchResponse(response);
|
|
await loadTransactions();
|
|
await updateTotals();
|
|
} catch (error) {
|
|
console.error('Error deleting transaction:', error);
|
|
alert('Failed to delete transaction. Please try again.');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading transactions:', error);
|
|
}
|
|
}
|
|
|
|
// Update editTransaction function
|
|
function editTransaction(id, transaction, isRecurringInstance) {
|
|
// For recurring instances, always use the base transaction ID
|
|
if (isRecurringInstance) {
|
|
// Extract the base transaction ID (everything before the date)
|
|
editingTransactionId = id.split('-202')[0]; // This will get the UUID part before the date
|
|
|
|
// Find the original transaction to get its start date
|
|
const startDate = transaction.recurring?.startDate || transaction.date;
|
|
transaction = { ...transaction, date: startDate };
|
|
} else {
|
|
editingTransactionId = id;
|
|
}
|
|
|
|
const modal = document.getElementById('transactionModal');
|
|
const form = document.getElementById('transactionForm');
|
|
const toggleBtns = document.querySelectorAll('.toggle-btn');
|
|
const categoryField = document.getElementById('categoryField');
|
|
const recurringCheckbox = document.getElementById('recurring-checkbox');
|
|
const recurringOptions = document.getElementById('recurring-options');
|
|
const recurringWeekday = document.getElementById('recurring-weekday');
|
|
const recurringInterval = document.getElementById('recurring-interval');
|
|
const recurringUnit = document.getElementById('recurring-unit');
|
|
const dayOfMonthSelect = document.getElementById('day-of-month-select');
|
|
|
|
// Set form values
|
|
document.getElementById('amount').value = transaction.amount;
|
|
document.getElementById('description').value = transaction.description;
|
|
document.getElementById('transactionDate').value = transaction.date;
|
|
|
|
// Set transaction type
|
|
toggleBtns.forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.type === transaction.type);
|
|
});
|
|
|
|
// Show/hide and set category for expenses
|
|
if (transaction.type === 'expense') {
|
|
categoryField.style.display = 'block';
|
|
document.getElementById('category').value = transaction.category;
|
|
} else {
|
|
categoryField.style.display = 'none';
|
|
}
|
|
|
|
// Set recurring options if this is a recurring transaction
|
|
if (transaction.recurring) {
|
|
recurringCheckbox.checked = true;
|
|
recurringOptions.style.display = 'block';
|
|
|
|
// Parse the recurring pattern
|
|
const pattern = transaction.recurring.pattern;
|
|
const monthlyDayMatch = pattern.match(/every (\d+)(?:st|nd|rd|th) of the month/);
|
|
const regularMatch = pattern.match(/every (\d+) (day|week|month|year)(?:\s+on\s+(\w+))?/);
|
|
|
|
if (monthlyDayMatch) {
|
|
recurringUnit.value = 'day of month';
|
|
dayOfMonthSelect.value = monthlyDayMatch[1];
|
|
dayOfMonthSelect.style.display = 'inline-block';
|
|
recurringInterval.style.display = 'none';
|
|
recurringWeekday.style.display = 'none';
|
|
} else if (regularMatch) {
|
|
const [, interval, unit, weekday] = regularMatch;
|
|
recurringInterval.value = interval;
|
|
recurringUnit.value = unit;
|
|
recurringInterval.style.display = 'inline-block';
|
|
dayOfMonthSelect.style.display = 'none';
|
|
|
|
if (unit === 'week' && weekday) {
|
|
recurringWeekday.style.display = 'inline-block';
|
|
recurringWeekday.value = weekday;
|
|
} else {
|
|
recurringWeekday.style.display = 'none';
|
|
}
|
|
}
|
|
} else {
|
|
recurringCheckbox.checked = false;
|
|
recurringOptions.style.display = 'none';
|
|
recurringWeekday.style.display = 'none';
|
|
recurringInterval.style.display = 'inline-block';
|
|
dayOfMonthSelect.style.display = 'none';
|
|
}
|
|
|
|
// Update form submit button text
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
submitBtn.textContent = 'Update';
|
|
|
|
// Show modal
|
|
modal.classList.add('active');
|
|
}
|
|
|
|
async function updateTotals() {
|
|
try {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
|
|
const response = await fetch(joinPath(`api/totals/range?start=${startDate}&end=${endDate}`), fetchConfig);
|
|
await handleFetchResponse(response);
|
|
const totals = await response.json();
|
|
document.getElementById('totalIncome').textContent = formatCurrency(totals.income);
|
|
document.getElementById('totalExpenses').textContent = formatCurrency(totals.expenses);
|
|
const balanceElement = document.getElementById('totalBalance');
|
|
balanceElement.textContent = formatCurrency(totals.balance);
|
|
|
|
// Add appropriate class based on balance value
|
|
balanceElement.classList.remove('positive', 'negative');
|
|
if (totals.balance > 0) {
|
|
balanceElement.classList.add('positive');
|
|
} else if (totals.balance < 0) {
|
|
balanceElement.classList.add('negative');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating totals:', error);
|
|
}
|
|
}
|
|
|
|
// Custom Categories Management
|
|
function loadCustomCategories() {
|
|
const customCategories = JSON.parse(localStorage.getItem('customCategories') || '[]');
|
|
const categorySelect = document.getElementById('category');
|
|
const addNewOption = categorySelect.querySelector('option[value="add_new"]');
|
|
|
|
// Remove existing custom categories
|
|
Array.from(categorySelect.options).forEach(option => {
|
|
if (option.dataset.custom === 'true') {
|
|
categorySelect.removeChild(option);
|
|
}
|
|
});
|
|
|
|
// Add custom categories before the "Add Category" option
|
|
customCategories.forEach(category => {
|
|
const option = document.createElement('option');
|
|
option.value = category;
|
|
option.textContent = category;
|
|
option.dataset.custom = 'true';
|
|
categorySelect.insertBefore(option, addNewOption);
|
|
});
|
|
}
|
|
|
|
function saveCustomCategory(category) {
|
|
const customCategories = JSON.parse(localStorage.getItem('customCategories') || '[]');
|
|
if (!customCategories.includes(category)) {
|
|
customCategories.push(category);
|
|
localStorage.setItem('customCategories', JSON.stringify(customCategories));
|
|
}
|
|
loadCustomCategories();
|
|
}
|
|
|
|
function initCategoryHandling() {
|
|
const categorySelect = document.getElementById('category');
|
|
const customCategoryField = document.getElementById('customCategoryField');
|
|
const customCategoryInput = document.getElementById('customCategory');
|
|
const saveCategoryBtn = document.getElementById('saveCategory');
|
|
const cancelCategoryBtn = document.getElementById('cancelCategory');
|
|
|
|
// Load custom categories on page load
|
|
loadCustomCategories();
|
|
|
|
categorySelect.addEventListener('change', (e) => {
|
|
if (e.target.value === 'add_new') {
|
|
customCategoryField.style.display = 'block';
|
|
categorySelect.style.display = 'none';
|
|
customCategoryInput.focus();
|
|
}
|
|
});
|
|
|
|
saveCategoryBtn.addEventListener('click', () => {
|
|
const newCategory = customCategoryInput.value.trim();
|
|
if (newCategory) {
|
|
saveCustomCategory(newCategory);
|
|
customCategoryField.style.display = 'none';
|
|
categorySelect.style.display = 'block';
|
|
categorySelect.value = newCategory;
|
|
customCategoryInput.value = '';
|
|
}
|
|
});
|
|
|
|
cancelCategoryBtn.addEventListener('click', () => {
|
|
customCategoryField.style.display = 'none';
|
|
categorySelect.style.display = 'block';
|
|
categorySelect.value = 'Other';
|
|
customCategoryInput.value = '';
|
|
});
|
|
|
|
// Handle Enter key in custom category input
|
|
customCategoryInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveCategoryBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update the initModalHandling function to include category handling
|
|
function initModalHandling() {
|
|
const modal = document.getElementById('transactionModal');
|
|
// Only initialize if we're on the main page
|
|
if (!modal) return;
|
|
|
|
const addTransactionBtn = document.getElementById('addTransactionBtn');
|
|
const closeModalBtn = document.querySelector('.close-modal');
|
|
const transactionForm = document.getElementById('transactionForm');
|
|
const categoryField = document.getElementById('categoryField');
|
|
const toggleBtns = document.querySelectorAll('.toggle-btn');
|
|
const amountInput = document.getElementById('amount');
|
|
|
|
let currentTransactionType = 'income';
|
|
|
|
// Initialize category handling
|
|
initCategoryHandling();
|
|
|
|
// Create and add recurring controls
|
|
const recurringControls = createRecurringControls();
|
|
transactionForm.appendChild(recurringControls);
|
|
recurringControls.style.display = 'block';
|
|
|
|
// Update amount input placeholder with current currency symbol
|
|
function updateAmountPlaceholder() {
|
|
const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD;
|
|
amountInput.placeholder = `Amount (${currencyInfo.symbol})`;
|
|
}
|
|
|
|
// Open modal
|
|
addTransactionBtn.addEventListener('click', () => {
|
|
modal.classList.add('active');
|
|
// Reset form
|
|
transactionForm.reset();
|
|
// Reset toggle buttons
|
|
toggleBtns.forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.type === 'income');
|
|
});
|
|
// Hide category field for income by default
|
|
categoryField.style.display = 'none';
|
|
currentTransactionType = 'income';
|
|
|
|
// Reset recurring options
|
|
document.getElementById('recurring-checkbox').checked = false;
|
|
document.getElementById('recurring-options').style.display = 'none';
|
|
document.getElementById('recurring-weekday').style.display = 'none';
|
|
|
|
// Set today's date as default
|
|
const today = new Date().toISOString().split('T')[0];
|
|
document.getElementById('transactionDate').value = today;
|
|
|
|
// Update amount placeholder with current currency
|
|
updateAmountPlaceholder();
|
|
});
|
|
|
|
// Close modal
|
|
const closeModal = () => {
|
|
modal.classList.remove('active');
|
|
editingTransactionId = null;
|
|
const submitBtn = transactionForm.querySelector('button[type="submit"]');
|
|
submitBtn.textContent = 'Add';
|
|
};
|
|
|
|
closeModalBtn.addEventListener('click', closeModal);
|
|
|
|
// Close modal when clicking outside
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Transaction type toggle
|
|
toggleBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
// Remove active class from all buttons
|
|
toggleBtns.forEach(b => b.classList.remove('active'));
|
|
// Add active class to clicked button
|
|
btn.classList.add('active');
|
|
|
|
currentTransactionType = btn.dataset.type;
|
|
|
|
// Show/hide category field based on transaction type
|
|
categoryField.style.display = currentTransactionType === 'expense' ? 'block' : 'none';
|
|
});
|
|
});
|
|
|
|
// Update form submission
|
|
transactionForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
type: currentTransactionType,
|
|
amount: parseFloat(document.getElementById('amount').value),
|
|
description: document.getElementById('description').value,
|
|
category: currentTransactionType === 'expense' ? document.getElementById('category').value : null,
|
|
date: document.getElementById('transactionDate').value,
|
|
recurring: buildRecurringPattern()
|
|
};
|
|
|
|
try {
|
|
const url = editingTransactionId
|
|
? joinPath(`api/transactions/${editingTransactionId}`)
|
|
: joinPath('api/transactions');
|
|
|
|
const method = editingTransactionId ? 'PUT' : 'POST';
|
|
|
|
const response = await fetch(url, {
|
|
...fetchConfig,
|
|
method,
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
await handleFetchResponse(response);
|
|
|
|
// Reset editing state
|
|
editingTransactionId = null;
|
|
|
|
// Update submit button text
|
|
const submitBtn = transactionForm.querySelector('button[type="submit"]');
|
|
submitBtn.textContent = 'Add';
|
|
|
|
// Close modal and reset form
|
|
closeModal();
|
|
transactionForm.reset();
|
|
|
|
// Refresh transactions list and totals
|
|
await loadTransactions();
|
|
await updateTotals();
|
|
} catch (error) {
|
|
console.error('Error saving transaction:', error);
|
|
alert('Failed to save transaction. Please try again.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add recurring transaction UI elements
|
|
function createRecurringControls() {
|
|
const container = document.createElement('div');
|
|
container.className = 'recurring-controls';
|
|
|
|
const checkboxWrapper = document.createElement('div');
|
|
checkboxWrapper.className = 'recurring-checkbox-wrapper';
|
|
checkboxWrapper.style.display = 'flex';
|
|
checkboxWrapper.style.alignItems = 'center';
|
|
checkboxWrapper.style.gap = '0.5rem';
|
|
checkboxWrapper.style.marginBottom = '1rem';
|
|
checkboxWrapper.style.width = 'fit-content';
|
|
checkboxWrapper.style.minWidth = '100px';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.id = 'recurring-checkbox';
|
|
checkbox.style.margin = '0';
|
|
|
|
const label = document.createElement('label');
|
|
label.htmlFor = 'recurring-checkbox';
|
|
label.textContent = 'Recurring';
|
|
label.style.margin = '0';
|
|
label.style.padding = '0';
|
|
label.style.cursor = 'pointer';
|
|
label.style.userSelect = 'none';
|
|
|
|
checkboxWrapper.appendChild(checkbox);
|
|
checkboxWrapper.appendChild(label);
|
|
|
|
const optionsDiv = document.createElement('div');
|
|
optionsDiv.id = 'recurring-options';
|
|
optionsDiv.style.display = 'none';
|
|
optionsDiv.className = 'recurring-options';
|
|
|
|
// Interval and unit wrapper
|
|
const intervalWrapper = document.createElement('div');
|
|
intervalWrapper.className = 'interval-wrapper';
|
|
|
|
// Interval input
|
|
const intervalInput = document.createElement('input');
|
|
intervalInput.type = 'number';
|
|
intervalInput.id = 'recurring-interval';
|
|
intervalInput.min = '1';
|
|
intervalInput.defaultValue = '1';
|
|
intervalInput.value = '1';
|
|
|
|
// Day of month select
|
|
const dayOfMonthSelect = document.createElement('select');
|
|
dayOfMonthSelect.id = 'day-of-month-select';
|
|
dayOfMonthSelect.style.display = 'none';
|
|
// Add options for days 1-31
|
|
for (let i = 1; i <= 31; i++) {
|
|
const option = document.createElement('option');
|
|
option.value = i;
|
|
option.textContent = `${i}${getDaySuffix(i)}`;
|
|
dayOfMonthSelect.appendChild(option);
|
|
}
|
|
|
|
// Unit select
|
|
const unitSelect = document.createElement('select');
|
|
unitSelect.id = 'recurring-unit';
|
|
const units = ['day', 'week', 'month', 'year', 'day of month'];
|
|
units.forEach(unit => {
|
|
const option = document.createElement('option');
|
|
option.value = unit;
|
|
option.textContent = unit === 'day of month' ? 'day of month' : unit + (unit === 'day' ? '' : 's');
|
|
unitSelect.appendChild(option);
|
|
});
|
|
|
|
intervalWrapper.appendChild(intervalInput);
|
|
intervalWrapper.appendChild(dayOfMonthSelect);
|
|
intervalWrapper.appendChild(unitSelect);
|
|
|
|
// Weekday select (for weekly recurrence)
|
|
const weekdaySelect = document.createElement('select');
|
|
weekdaySelect.id = 'recurring-weekday';
|
|
weekdaySelect.style.display = 'none';
|
|
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
weekdays.forEach(day => {
|
|
const option = document.createElement('option');
|
|
option.value = day;
|
|
option.textContent = day.charAt(0).toUpperCase() + day.slice(1);
|
|
weekdaySelect.appendChild(option);
|
|
});
|
|
|
|
// Event listeners
|
|
checkbox.addEventListener('change', () => {
|
|
optionsDiv.style.display = checkbox.checked ? 'block' : 'none';
|
|
});
|
|
|
|
unitSelect.addEventListener('change', () => {
|
|
weekdaySelect.style.display = unitSelect.value === 'week' ? 'inline-block' : 'none';
|
|
intervalInput.style.display = unitSelect.value === 'day of month' ? 'none' : 'inline-block';
|
|
dayOfMonthSelect.style.display = unitSelect.value === 'day of month' ? 'inline-block' : 'none';
|
|
});
|
|
|
|
// Assemble the controls
|
|
optionsDiv.appendChild(intervalWrapper);
|
|
optionsDiv.appendChild(weekdaySelect);
|
|
|
|
container.appendChild(checkboxWrapper);
|
|
container.appendChild(optionsDiv);
|
|
|
|
return container;
|
|
}
|
|
|
|
// Function to build the recurring pattern string
|
|
function buildRecurringPattern() {
|
|
const checkbox = document.getElementById('recurring-checkbox');
|
|
if (!checkbox.checked) return null;
|
|
|
|
const unit = document.getElementById('recurring-unit').value;
|
|
|
|
if (unit === 'day of month') {
|
|
const dayNum = document.getElementById('day-of-month-select').value;
|
|
const suffix = getDaySuffix(dayNum);
|
|
return {
|
|
pattern: `every ${dayNum}${suffix} of the month`,
|
|
until: null
|
|
};
|
|
}
|
|
|
|
const interval = document.getElementById('recurring-interval').value;
|
|
const weekday = document.getElementById('recurring-weekday').value;
|
|
|
|
let pattern = `every ${interval} ${unit}`;
|
|
if (unit === 'week' && weekday) {
|
|
pattern += ` on ${weekday}`;
|
|
}
|
|
|
|
return {
|
|
pattern,
|
|
until: null
|
|
};
|
|
}
|
|
|
|
// Helper function to get the correct suffix for a day number
|
|
function getDaySuffix(day) {
|
|
if (day >= 11 && day <= 13) return 'th';
|
|
|
|
switch (day % 10) {
|
|
case 1: return 'st';
|
|
case 2: return 'nd';
|
|
case 3: return 'rd';
|
|
default: return 'th';
|
|
}
|
|
}
|
|
|
|
// Update the initMainPage function to fetch currency first
|
|
async function initMainPage() {
|
|
await fetchCurrentCurrency();
|
|
const mainContainer = document.getElementById('transactionModal');
|
|
if (!mainContainer) return; // Only run on main page
|
|
|
|
// Update currency symbols
|
|
const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD;
|
|
document.querySelector('.currency-sort-symbol').textContent = currencyInfo.symbol;
|
|
document.querySelector('.currency-symbol').textContent = currencyInfo.symbol;
|
|
|
|
// Update amount placeholder when currency changes
|
|
const amountInput = document.getElementById('amount');
|
|
if (amountInput) {
|
|
amountInput.placeholder = `Amount (${currencyInfo.symbol})`;
|
|
}
|
|
|
|
const startDateInput = document.getElementById('startDate');
|
|
const endDateInput = document.getElementById('endDate');
|
|
|
|
// Set initial date range to current month
|
|
const now = new Date();
|
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
|
|
startDateInput.value = firstDay.toISOString().split('T')[0];
|
|
endDateInput.value = lastDay.toISOString().split('T')[0];
|
|
|
|
// Add event listeners for date changes
|
|
startDateInput.addEventListener('change', () => {
|
|
if (startDateInput.value > endDateInput.value) {
|
|
endDateInput.value = startDateInput.value;
|
|
}
|
|
loadTransactions();
|
|
updateTotals();
|
|
});
|
|
|
|
endDateInput.addEventListener('change', () => {
|
|
if (endDateInput.value < startDateInput.value) {
|
|
startDateInput.value = endDateInput.value;
|
|
}
|
|
loadTransactions();
|
|
updateTotals();
|
|
});
|
|
|
|
// Export to CSV
|
|
document.getElementById('exportBtn').addEventListener('click', async () => {
|
|
try {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const response = await fetch(joinPath(`api/export/range?start=${startDate}&end=${endDate}`), {
|
|
...fetchConfig,
|
|
method: 'GET'
|
|
});
|
|
|
|
// Use the same response handler as other requests
|
|
const handledResponse = await handleFetchResponse(response);
|
|
if (!handledResponse) return;
|
|
|
|
const blob = await handledResponse.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `transactions-${startDate}-to-${endDate}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
} catch (error) {
|
|
console.error('Error exporting transactions:', error);
|
|
alert('Failed to export transactions. Please try again.');
|
|
}
|
|
});
|
|
|
|
// Add filter button handlers
|
|
const filterButtons = document.querySelectorAll('.filter-btn');
|
|
filterButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const filterType = btn.dataset.filter;
|
|
|
|
// Remove active class from all buttons
|
|
filterButtons.forEach(b => b.classList.remove('active'));
|
|
|
|
if (currentFilter === filterType) {
|
|
// If clicking the active filter, clear it
|
|
currentFilter = null;
|
|
} else {
|
|
// Set new filter and activate button
|
|
currentFilter = filterType;
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
loadTransactions();
|
|
});
|
|
});
|
|
|
|
// Initialize sort controls
|
|
const sortButtons = document.querySelectorAll('.sort-btn');
|
|
const sortDirection = document.getElementById('sortDirection');
|
|
|
|
sortButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
// Remove active class from all buttons
|
|
sortButtons.forEach(b => b.classList.remove('active'));
|
|
// Add active class to clicked button
|
|
btn.classList.add('active');
|
|
|
|
currentSortField = btn.dataset.sort;
|
|
loadTransactions();
|
|
});
|
|
});
|
|
|
|
sortDirection.addEventListener('click', () => {
|
|
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
|
sortDirection.classList.toggle('descending', currentSortDirection === 'desc');
|
|
loadTransactions();
|
|
});
|
|
|
|
// Set initial sort direction indicator
|
|
sortDirection.classList.toggle('descending', currentSortDirection === 'desc');
|
|
|
|
// Initial load
|
|
loadTransactions();
|
|
updateTotals();
|
|
}
|
|
|
|
// Initialize functionality
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initThemeToggle();
|
|
|
|
// Check which page we're on
|
|
const isLoginPage = window.location.pathname.includes('login');
|
|
|
|
if (isLoginPage) {
|
|
// Only initialize PIN inputs on login page
|
|
setupPinInputs();
|
|
} else {
|
|
// Only initialize main page functionality when not on login
|
|
initModalHandling();
|
|
initMainPage();
|
|
}
|
|
});
|