class BudgetTracker {
constructor() {
this.db = null;
this.transactions = [];
this.categories = {
income: ['เงินเดือน', 'โบนัส', 'รายได้เสริม', 'ของขวัญ', 'อื่นๆ'],
expense: ['อาหาร', 'เดินทาง', 'บันเทิง', 'ช้อปปิ้ง', 'สุขภาพ', 'การศึกษา', 'อื่นๆ']
};
this.init();
}
async init() {
await this.initDB();
await this.loadTransactions();
this.setupEventListeners();
this.updateSummary();
this.renderTransactions();
// Set default date to today
document.getElementById('date').value = new Date().toISOString().split('T')[0];
}
async initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('BudgetTrackerDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
// If the expected object store is missing (e.g., older DB), upgrade DB to create it.
if (!this.db.objectStoreNames.contains('transactions')) {
const newVersion = this.db.version + 1;
this.db.close();
const upgradeReq = indexedDB.open('BudgetTrackerDB', newVersion);
upgradeReq.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('transactions')) {
const store = db.createObjectStore('transactions', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('date', 'date', {unique: false});
store.createIndex('category', 'category', {unique: false});
store.createIndex('type', 'type', {unique: false});
}
};
upgradeReq.onerror = () => reject(upgradeReq.error);
upgradeReq.onsuccess = () => {
this.db = upgradeReq.result;
resolve();
};
} else {
resolve();
}
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore('transactions', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('date', 'date', {unique: false});
store.createIndex('category', 'category', {unique: false});
store.createIndex('type', 'type', {unique: false});
};
});
}
async loadTransactions() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['transactions'], 'readonly');
const store = transaction.objectStore('transactions');
const request = store.getAll();
request.onsuccess = () => {
this.transactions = request.result;
resolve();
};
request.onerror = () => reject(request.error);
});
}
async saveTransaction(transaction) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['transactions'], 'readwrite');
const store = tx.objectStore('transactions');
const request = store.add(transaction);
request.onsuccess = () => {
transaction.id = request.result;
this.transactions.push(transaction);
resolve(transaction);
};
request.onerror = () => reject(request.error);
});
}
setupEventListeners() {
// Form submission
document.getElementById('transactionForm').addEventListener('submit', (e) => {
e.preventDefault();
this.addTransaction();
});
// Type change - update categories
document.getElementById('type').addEventListener('change', (e) => {
this.updateCategories(e.target.value);
});
// Date filter
document.getElementById('filterDate').addEventListener('change', (e) => {
this.renderTransactions(e.target.value);
});
// AI categorize button
document.getElementById('categorizeBtn').addEventListener('click', () => {
this.categorizeWithAI();
});
}
updateCategories(type) {
const categorySelect = document.getElementById('category');
categorySelect.innerHTML = '<option value="">เลือกหมวดหมู่</option>';
if (type && this.categories[type]) {
// enable select
categorySelect.disabled = false;
this.categories[type].forEach((category, idx) => {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
categorySelect.appendChild(option);
});
// auto-select the first real category to avoid required validation blocking
// Prefer default 'อื่นๆ' if present, otherwise select first
const defaultIndex = Array.from(categorySelect.options).findIndex(o => o.value === 'อื่นๆ');
if (defaultIndex > 0) {
categorySelect.selectedIndex = defaultIndex;
} else if (categorySelect.options.length > 1) {
categorySelect.selectedIndex = 1;
}
} else {
// no type selected -> disable category
categorySelect.disabled = true;
}
}
async addTransaction() {
const formData = {
type: document.getElementById('type').value,
category: document.getElementById('category').value,
description: document.getElementById('description').value,
amount: parseFloat(document.getElementById('amount').value),
date: document.getElementById('date').value,
timestamp: new Date().toISOString()
};
try {
await this.saveTransaction(formData);
this.updateSummary();
this.renderTransactions();
this.resetForm();
// Show success message
this.showMessage('เพิ่มรายการเรียบร้อยแล้ว', 'success');
} catch (error) {
console.error('Error saving transaction:', error);
this.showMessage('เกิดข้อผิดพลาดในการบันทึกข้อมูล', 'error');
}
}
resetForm() {
document.getElementById('transactionForm').reset();
document.getElementById('date').value = new Date().toISOString().split('T')[0];
const categorySelect = document.getElementById('category');
categorySelect.innerHTML = '<option value="">เลือกหมวดหมู่</option>';
categorySelect.disabled = true;
}
updateSummary() {
const totalIncome = this.transactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amount, 0);
const totalExpense = this.transactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amount, 0);
const balance = totalIncome - totalExpense;
document.getElementById('totalIncome').textContent = this.formatCurrency(totalIncome);
document.getElementById('totalExpense').textContent = this.formatCurrency(totalExpense);
document.getElementById('balance').textContent = this.formatCurrency(balance);
}
renderTransactions(filterDate = null) {
const container = document.getElementById('transactionsList');
let filteredTransactions = [...this.transactions];
if (filterDate) {
filteredTransactions = filteredTransactions.filter(t => t.date === filterDate);
}
// Sort by date (newest first)
filteredTransactions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (filteredTransactions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="material-icons">receipt</div>
<p>${filterDate ? 'ไม่พบรายการในวันที่เลือก' : 'ยังไม่มีรายการ เพิ่มรายการแรกของคุณเลย!'}</p>
</div>
`;
return;
}
container.innerHTML = filteredTransactions.map(transaction => {
const icon = this.getCategoryIcon(transaction.category, transaction.type);
const iconClass = this.getCategoryClass(transaction.category, transaction.type);
return `
<div class="transaction-item">
<div class="transaction-info">
<div class="transaction-icon ${iconClass}">
<span class="material-icons">${icon}</span>
</div>
<div class="transaction-details">
<h4>${transaction.description}</h4>
<p>${transaction.category} • ${this.formatDate(transaction.date)}</p>
</div>
</div>
<div class="transaction-amount ${transaction.type}">
${transaction.type === 'income' ? '+' : '-'}${this.formatCurrency(transaction.amount)}
</div>
</div>
`;
}).join('');
}
getCategoryIcon(category, type) {
if (type === 'income') return 'trending_up';
const iconMap = {
'อาหาร': 'restaurant',
'เดินทาง': 'directions_car',
'บันเทิง': 'movie',
'ช้อปปิ้ง': 'shopping_bag',
'สุขภาพ': 'local_hospital',
'การศึกษา': 'school'
};
return iconMap[category] || 'payment';
}
getCategoryClass(category, type) {
if (type === 'income') return 'income';
const classMap = {
'อาหาร': 'food',
'เดินทาง': 'transport',
'บันเทิง': 'entertainment'
};
return classMap[category] || 'other';
}
formatCurrency(amount) {
return `฿${amount.toLocaleString('th-TH', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
}
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('th-TH', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Normalize description for better matching
normalizeDescription(desc) {
return desc.trim().toLowerCase()
.replace(/[^\u0E00-\u0E7Fa-zA-Z0-9\s]/g, '') // keep Thai, English, numbers, spaces
.replace(/\s+/g, ' '); // normalize whitespace
}
async categorizeWithAI() {
// Find uncategorized transactions for both income and expense
const uncategorizedIncome = this.transactions.filter(t =>
t.type === 'income' && (t.category === 'อื่นๆ' || !t.category)
);
const uncategorizedExpenses = this.transactions.filter(t =>
t.type === 'expense' && (t.category === 'อื่นๆ' || !t.category)
);
const totalUncategorized = uncategorizedIncome.length + uncategorizedExpenses.length;
if (totalUncategorized === 0) {
this.showMessage('ไม่มีรายการที่ต้องจัดหมวดหมู่', 'success');
return;
}
this.showAIStatus(`เริ่มจัดหมวดหมู่ ${totalUncategorized} รายการ...`, 'loading');
let totalUpdated = 0;
const promptEl = document.getElementById('aiPrompt');
try {
// Process expenses first
if (uncategorizedExpenses.length > 0) {
this.showAIStatus(`กำลังจัดหมวดรายจ่าย ${uncategorizedExpenses.length} รายการ...`, 'loading');
const expenseList = uncategorizedExpenses.map(t => this.normalizeDescription(t.description)).join(', ');
const expensePrompt = `Categorize these Thai expenses into Food, Transport, Entertainment: ${expenseList}. Return only a JSON object with format: {"item1": "category", "item2": "category"}. Use only these categories: Food, Transport, Entertainment.`;
promptEl.textContent = `[รายจ่าย] ${expensePrompt}`;
promptEl.style.display = 'block';
const expenseResponse = await fetch('ai_categorize.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: expensePrompt})
});
if (expenseResponse.ok) {
const expenseResult = await expenseResponse.json();
if (expenseResult.success && expenseResult.categories) {
const expenseCategoryMap = {
'Food': 'อาหาร',
'Transport': 'เดินทาง',
'Entertainment': 'บันเทิง'
};
for (const transaction of uncategorizedExpenses) {
const normalizedDesc = this.normalizeDescription(transaction.description);
const aiCategory = expenseResult.categories[normalizedDesc];
if (aiCategory && expenseCategoryMap[aiCategory]) {
transaction.category = expenseCategoryMap[aiCategory];
await this.updateTransactionInDB(transaction);
totalUpdated++;
}
}
}
}
}
// Process income second
if (uncategorizedIncome.length > 0) {
this.showAIStatus(`กำลังจัดหมวดรายรับ ${uncategorizedIncome.length} รายการ...`, 'loading');
const incomeList = uncategorizedIncome.map(t => this.normalizeDescription(t.description)).join(', ');
const incomePrompt = `Categorize these Thai income sources into Salary, Bonus, SideIncome, Gift: ${incomeList}. Return only a JSON object with format: {"item1": "category", "item2": "category"}. Use only these categories: Salary, Bonus, SideIncome, Gift.`;
promptEl.textContent = `[รายรับ] ${incomePrompt}`;
const incomeResponse = await fetch('ai_categorize.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: incomePrompt})
});
if (incomeResponse.ok) {
const incomeResult = await incomeResponse.json();
if (incomeResult.success && incomeResult.categories) {
const incomeCategoryMap = {
'Salary': 'เงินเดือน',
'Bonus': 'โบนัส',
'SideIncome': 'รายได้เสริม',
'Gift': 'ของขวัญ'
};
for (const transaction of uncategorizedIncome) {
const normalizedDesc = this.normalizeDescription(transaction.description);
const aiCategory = incomeResult.categories[normalizedDesc];
if (aiCategory && incomeCategoryMap[aiCategory]) {
transaction.category = incomeCategoryMap[aiCategory];
await this.updateTransactionInDB(transaction);
totalUpdated++;
}
}
}
}
}
this.renderTransactions();
this.showAIStatus(`จัดหมวดหมู่สำเร็จ ${totalUpdated} จาก ${totalUncategorized} รายการ`, 'success');
promptEl.style.display = 'none';
} catch (error) {
console.error('AI categorization error:', error);
this.showAIStatus('เกิดข้อผิดพลาดในการจัดหมวดหมู่', 'error');
promptEl.style.display = 'none';
}
}
async updateTransactionInDB(transaction) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(['transactions'], 'readwrite');
const store = tx.objectStore('transactions');
const request = store.put(transaction);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
showAIStatus(message, type) {
const statusEl = document.getElementById('aiStatus');
statusEl.textContent = message;
statusEl.className = `ai-status ${type}`;
statusEl.style.display = 'block';
if (type === 'success' || type === 'error') {
setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
}
showMessage(message, type) {
// Create a temporary message element
const msgEl = document.createElement('div');
msgEl.className = `ai-status ${type}`;
msgEl.textContent = message;
msgEl.style.position = 'fixed';
msgEl.style.top = '20px';
msgEl.style.right = '20px';
msgEl.style.zIndex = '1000';
msgEl.style.maxWidth = '300px';
document.body.appendChild(msgEl);
setTimeout(() => {
document.body.removeChild(msgEl);
}, 3000);
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new BudgetTracker();
});