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 = ''; 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 = ''; 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 = `
receipt

${filterDate ? 'ไม่พบรายการในวันที่เลือก' : 'ยังไม่มีรายการ เพิ่มรายการแรกของคุณเลย!'}

`; return; } container.innerHTML = filteredTransactions.map(transaction => { const icon = this.getCategoryIcon(transaction.category, transaction.type); const iconClass = this.getCategoryClass(transaction.category, transaction.type); return `
${icon}

${transaction.description}

${transaction.category} • ${this.formatDate(transaction.date)}

${transaction.type === 'income' ? '+' : '-'}${this.formatCurrency(transaction.amount)}
`; }).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(); });