app.js

16.43 KB
12/09/2025 04:52
JS
app.js

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();
});